From 93bf4724724da3bff0f199fc0a4b70bb6f59ccbd Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 24 Mar 2026 22:51:44 +0800 Subject: [PATCH] Text selection --- .../MultilineTextWithEntitiesComponent.swift | 2 +- .../Sources/ResizableSheetComponent.swift | 2 +- .../Source/ContextMenuController.swift | 16 ++- .../ChatItemGalleryFooterContentNode.swift | 6 +- ...hatMessageFactCheckBubbleContentNode.swift | 6 +- .../ChatMessageInteractiveFileNode.swift | 6 +- .../ChatMessageTextBubbleContentNode.swift | 6 +- .../Sources/ChatTextInputPanelNode.swift | 12 +- .../Sources/ContextActionsContainerNode.swift | 6 +- .../Sources/ContextMenuController.swift | 6 +- .../Sources/CreateBotScreen.swift | 2 +- .../StoryContentCaptionComponent.swift | 6 +- .../Components/TextProcessingScreen/BUILD | 3 + .../Sources/TextProcessingScreen.swift | 26 +++- .../TextProcessingTextAreaComponent.swift | 122 +++++++++++++++++- ...tProcessingTranslateContentComponent.swift | 18 ++- .../Sources/TextSelectionNode.swift | 81 +++++++++--- 17 files changed, 266 insertions(+), 60 deletions(-) diff --git a/submodules/Components/MultilineTextWithEntitiesComponent/Sources/MultilineTextWithEntitiesComponent.swift b/submodules/Components/MultilineTextWithEntitiesComponent/Sources/MultilineTextWithEntitiesComponent.swift index 2519f2f6eb..55633a2b99 100644 --- a/submodules/Components/MultilineTextWithEntitiesComponent/Sources/MultilineTextWithEntitiesComponent.swift +++ b/submodules/Components/MultilineTextWithEntitiesComponent/Sources/MultilineTextWithEntitiesComponent.swift @@ -190,7 +190,7 @@ public final class MultilineTextWithEntitiesComponent: Component { public final class View: UIView { var spoilerTextNode: ImmediateTextNodeWithEntities? - let textNode: ImmediateTextNodeWithEntities + public let textNode: ImmediateTextNodeWithEntities public override init(frame: CGRect) { self.textNode = ImmediateTextNodeWithEntities() diff --git a/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift b/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift index ff8fe0d47a..5b1e58f2dd 100644 --- a/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift +++ b/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift @@ -466,7 +466,7 @@ public final class ResizableSheetComponent (ASDisplayNode, CGRect, ASDisplayNode, CGRect)? + public let sourceViewAndRect: () -> (UIView, CGRect, UIView, CGRect)? public let bounce: Bool - public init(sourceNodeAndRect: @escaping () -> (ASDisplayNode, CGRect, ASDisplayNode, CGRect)?, bounce: Bool = true) { - self.sourceNodeAndRect = sourceNodeAndRect + public init(sourceViewAndRect: @escaping () -> (UIView, CGRect, UIView, CGRect)?, bounce: Bool = true) { + self.sourceViewAndRect = sourceViewAndRect self.bounce = bounce } + + public convenience init(sourceNodeAndRect: @escaping () -> (ASDisplayNode, CGRect, ASDisplayNode, CGRect)?, bounce: Bool = true) { + self.init(sourceViewAndRect: { + if let (view1, rect1, view2, rect2) = sourceNodeAndRect() { + return (view1.view, rect1, view2.view, rect2) + } else { + return nil + } + }, bounce: bounce) + } } public protocol ContextMenuController: ViewController, StandalonePresentableController { diff --git a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift index a673be983b..a24b6f9b7d 100644 --- a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift +++ b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift @@ -459,18 +459,18 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, ASScroll ) self.textNode.visibility = true - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: defaultDarkPresentationTheme.list.itemAccentColor.withMultipliedAlpha(0.5), knob: defaultDarkPresentationTheme.list.itemAccentColor, isDark: true), strings: presentationData.strings, textNode: self.textNode, updateIsActive: { [weak self] value in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: defaultDarkPresentationTheme.list.itemAccentColor.withMultipliedAlpha(0.5), knob: defaultDarkPresentationTheme.list.itemAccentColor, isDark: true), strings: presentationData.strings, textNodeOrView: .node(self.textNode), updateIsActive: { [weak self] value in guard let self else { return } let _ = self }, present: { c, a in present(c, a) - }, rootNode: { [weak self] in + }, rootView: { [weak self] in guard let self else { return nil } - return self.controllerInteraction?.controller()?.displayNode + return self.controllerInteraction?.controller()?.displayNode.view }, externalKnobSurface: self.textSelectionKnobSurface, performAction: { [weak self] text, action in guard let self else { return diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift index 294d430e00..33a6223ac6 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift @@ -156,12 +156,12 @@ public class ChatMessageFactCheckBubbleContentNode: ChatMessageBubbleContentNode let selectionColor: UIColor = item.presentationData.theme.theme.chat.message.incoming.textSelectionColor let knobColor: UIColor = item.presentationData.theme.theme.chat.message.incoming.textSelectionKnobColor - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNode: self.textNode, updateIsActive: { [weak self] value in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNodeOrView: .node(self.textNode), updateIsActive: { [weak self] value in self?.updateIsTextSelectionActive?(value) }, present: { [weak self] c, a in self?.item?.controllerInteraction.presentGlobalOverlayController(c, a) - }, rootNode: { [weak rootNode] in - return rootNode + }, rootView: { [weak rootNode] in + return rootNode?.view }, performAction: { [weak self] text, action in guard let strongSelf = self, let item = strongSelf.item else { return diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift index 63ec5d3875..cbff3c4238 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift @@ -2049,12 +2049,12 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode { knobColor = item.presentationData.theme.theme.chat.message.outgoing.textSelectionKnobColor } - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNode: self.textNode, updateIsActive: { [weak self] value in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNodeOrView: .node(self.textNode), updateIsActive: { [weak self] value in self?.updateIsTextSelectionActive?(value) }, present: { [weak self] c, a in self?.arguments?.controllerInteraction.presentGlobalOverlayController(c, a) - }, rootNode: { [weak rootNode] in - return rootNode + }, rootView: { [weak rootNode] in + return rootNode?.view }, performAction: { [weak self] text, action in guard let strongSelf = self, let item = strongSelf.arguments else { return diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index a6b8ad6925..931ccd6ec2 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -1622,7 +1622,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { knobColor = item.presentationData.theme.theme.chat.message.outgoing.textSelectionKnobColor } - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNode: self.textNode.textNode, updateIsActive: { [weak self] value in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNodeOrView: .node(self.textNode.textNode), updateIsActive: { [weak self] value in self?.updateIsTextSelectionActive?(value) }, present: { [weak self] c, a in guard let self, let item = self.item else { @@ -1634,8 +1634,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } else { item.controllerInteraction.presentGlobalOverlayController(c, a) } - }, rootNode: { [weak rootNode] in - return rootNode + }, rootView: { [weak rootNode] in + return rootNode?.view }, performAction: { [weak self] text, action in guard let strongSelf = self, let item = strongSelf.item else { return diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index ed68788e9b..d31dedf2e7 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -3562,14 +3562,14 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg var aiButtonAlpha: CGFloat = actualTextFieldFrame.height >= 70.0 ? 1.0 : 0.0 self.heightDependentAiButtonAlpha = aiButtonAlpha var inputHasText = false - if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, attributedText.length != 0 { + if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, !attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { inputHasText = true } if !inputHasText { aiButtonAlpha = 0.0 } - transition.updateAlpha(layer: aiButton.button.layer, alpha: aiButtonAlpha) - transition.updateAlpha(layer: aiButton.icon.layer, alpha: aiButtonAlpha) + ComponentTransition(transition).setAlpha(view: aiButton.button, alpha: aiButtonAlpha) + ComponentTransition(transition).setAlpha(view: aiButton.icon, alpha: aiButtonAlpha) } else if let aiButton = self.aiButton { self.aiButton = nil let aiButtonView = aiButton.button @@ -4398,11 +4398,11 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg if let aiButton = self.aiButton { let transition: ContainedViewLayoutTransition = .immediate var aiButtonAlpha: CGFloat = self.heightDependentAiButtonAlpha - if !inputHasText { + if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, !attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { aiButtonAlpha = 0.0 } - transition.updateAlpha(layer: aiButton.button.layer, alpha: aiButtonAlpha) - transition.updateAlpha(layer: aiButton.icon.layer, alpha: aiButtonAlpha) + ComponentTransition(transition).setAlpha(view: aiButton.button, alpha: aiButtonAlpha) + ComponentTransition(transition).setAlpha(view: aiButton.icon, alpha: aiButtonAlpha) } self.updateTextHeight(animated: animated) diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift index 077af06fa7..0ef201b886 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift @@ -438,10 +438,10 @@ final class InnerTextSelectionTipContainerNode: ASDisplayNode { super.init() - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: presentationData.theme.contextMenu.primaryColor.withAlphaComponent(0.15), knob: presentationData.theme.contextMenu.primaryColor, knobDiameter: 8.0, isDark: presentationData.theme.overallDarkAppearance), strings: presentationData.strings, textNode: self.textNode.textNode, updateIsActive: { _ in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: presentationData.theme.contextMenu.primaryColor.withAlphaComponent(0.15), knob: presentationData.theme.contextMenu.primaryColor, knobDiameter: 8.0, isDark: presentationData.theme.overallDarkAppearance), strings: presentationData.strings, textNodeOrView: .node(self.textNode.textNode), updateIsActive: { _ in }, present: { _, _ in - }, rootNode: { [weak self] in - return self + }, rootView: { [weak self] in + return self?.view }, performAction: { _, _ in }) self.textSelectionNode = textSelectionNode diff --git a/submodules/TelegramUI/Components/ContextMenuScreen/Sources/ContextMenuController.swift b/submodules/TelegramUI/Components/ContextMenuScreen/Sources/ContextMenuController.swift index 70ffcd512f..ee0f69bba6 100644 --- a/submodules/TelegramUI/Components/ContextMenuScreen/Sources/ContextMenuController.swift +++ b/submodules/TelegramUI/Components/ContextMenuScreen/Sources/ContextMenuController.swift @@ -85,13 +85,13 @@ public final class ContextMenuControllerImpl: ViewController, KeyShortcutRespond } else { self.layout = layout - if let presentationArguments = self.presentationArguments as? ContextMenuControllerPresentationArguments, let (sourceNode, sourceRect, containerNode, containerRect) = presentationArguments.sourceNodeAndRect() { + if let presentationArguments = self.presentationArguments as? ContextMenuControllerPresentationArguments, let (sourceView, sourceRect, containerView, containerRect) = presentationArguments.sourceViewAndRect() { if self.skipCoordnateConversion { self.contextMenuNode.sourceRect = sourceRect self.contextMenuNode.containerRect = containerRect } else { - self.contextMenuNode.sourceRect = sourceNode.view.convert(sourceRect, to: nil) - self.contextMenuNode.containerRect = containerNode.view.convert(containerRect, to: nil) + self.contextMenuNode.sourceRect = sourceView.convert(sourceRect, to: nil) + self.contextMenuNode.containerRect = containerView.convert(containerRect, to: nil) } } else { self.contextMenuNode.sourceRect = nil diff --git a/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift b/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift index b3f5be4b45..fda1953b09 100644 --- a/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift +++ b/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift @@ -402,7 +402,7 @@ final class CreateBotContentComponent: Component { theme: environment.theme, strings: environment.strings, action: { [weak self] in - guard let self, let itemView = self.nameSection.findTaggedView(tag: self.usernameInputTag) as? ListMultilineTextFieldItemComponent.View else { + guard let self, let itemView = self.usernameSection.findTaggedView(tag: self.usernameInputTag) as? ListMultilineTextFieldItemComponent.View else { return } itemView.activateInput() diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift index 2707b92685..d16cafd51c 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift @@ -896,7 +896,7 @@ final class StoryContentCaptionComponent: Component { self.textSelectionKnobContainer.addSubview(textSelectionKnobSurface) } - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: component.theme.list.itemAccentColor, isDark: true), strings: component.strings, textNode: textNode, updateIsActive: { [weak self] value in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: component.theme.list.itemAccentColor, isDark: true), strings: component.strings, textNodeOrView: .node(textNode), updateIsActive: { [weak self] value in guard let self else { return } @@ -912,8 +912,8 @@ final class StoryContentCaptionComponent: Component { return } component.controller()?.presentInGlobalOverlay(c, with: a) - }, rootNode: { [weak controller] in - return controller?.displayNode + }, rootView: { [weak controller] in + return controller?.displayNode.view }, externalKnobSurface: self.textSelectionKnobSurface, performAction: { [weak self] text, action in guard let self, let component = self.component else { return diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/BUILD b/submodules/TelegramUI/Components/TextProcessingScreen/BUILD index ff2ce1a75b..f9f67f5d42 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/BUILD +++ b/submodules/TelegramUI/Components/TextProcessingScreen/BUILD @@ -41,6 +41,9 @@ swift_library( "//submodules/Markdown", "//submodules/TelegramUIPreferences", "//submodules/ChatSendMessageActionUI", + "//submodules/TextSelectionNode", + "//submodules/Pasteboard", + "//submodules/Speak", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift index 67050a9997..c501ec2c0c 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift @@ -84,6 +84,7 @@ final class TextProcessingContentComponent: Component { final class View: UIView { private var component: TextProcessingContentComponent? + private var environment: ViewControllerComponentContainer.Environment? private weak var state: EmptyComponentState? private var isUpdating: Bool = false @@ -213,6 +214,7 @@ final class TextProcessingContentComponent: Component { } self.component = component + self.environment = environment self.state = state let sideInset: CGFloat = 16.0 @@ -351,7 +353,13 @@ final class TextProcessingContentComponent: Component { inputText: component.inputText, mode: .translate, copyAction: component.copyCurrentResult, - displayLanguageSelectionMenu: component.displayLanguageSelectionMenu + displayLanguageSelectionMenu: component.displayLanguageSelectionMenu, + present: { [weak self] c, a in + self?.environment?.controller()?.present(c, in: .window(.root), with: a) + }, + rootViewForTextSelection: { [weak self] in + return self?.environment?.controller()?.view + } )) case .stylize: contentComponent = AnyComponent(TextProcessingTranslateContentComponent( @@ -363,7 +371,13 @@ final class TextProcessingContentComponent: Component { inputText: component.inputText, mode: .stylize, copyAction: component.copyCurrentResult, - displayLanguageSelectionMenu: component.displayLanguageSelectionMenu + displayLanguageSelectionMenu: component.displayLanguageSelectionMenu, + present: { [weak self] c, a in + self?.environment?.controller()?.present(c, in: .window(.root), with: a) + }, + rootViewForTextSelection: { [weak self] in + return self?.environment?.controller()?.view + } )) case .fix: contentComponent = AnyComponent(TextProcessingTranslateContentComponent( @@ -375,7 +389,13 @@ final class TextProcessingContentComponent: Component { inputText: component.inputText, mode: .fix, copyAction: component.copyCurrentResult, - displayLanguageSelectionMenu: component.displayLanguageSelectionMenu + displayLanguageSelectionMenu: component.displayLanguageSelectionMenu, + present: { [weak self] c, a in + self?.environment?.controller()?.present(c, in: .window(.root), with: a) + }, + rootViewForTextSelection: { [weak self] in + return self?.environment?.controller()?.view + } )) } diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTextAreaComponent.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTextAreaComponent.swift index ac3224fa07..2ca48b76a2 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTextAreaComponent.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTextAreaComponent.swift @@ -13,10 +13,14 @@ import TextFormat import PlainButtonComponent import CheckComponent import ShimmerEffect +import TextSelectionNode +import Pasteboard +import Speak final class TextProcessingTextAreaComponent: Component { let context: AccountContext let theme: PresentationTheme + let strings: PresentationStrings let titlePrefix: String let title: String let titleAction: ((UIView) -> Void)? @@ -26,10 +30,13 @@ final class TextProcessingTextAreaComponent: Component { let text: TextWithEntities? let loadingStateMeasuringText: String? let textCorrectionRanges: [Range] + let present: (ViewController, Any?) -> Void + let rootViewForTextSelection: () -> UIView? init( context: AccountContext, theme: PresentationTheme, + strings: PresentationStrings, titlePrefix: String, title: String, titleAction: ((UIView) -> Void)?, @@ -38,10 +45,13 @@ final class TextProcessingTextAreaComponent: Component { emojify: (value: Bool, toggle: () -> Void)?, text: TextWithEntities?, loadingStateMeasuringText: String?, - textCorrectionRanges: [Range] + textCorrectionRanges: [Range], + 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 @@ -51,6 +61,8 @@ final class TextProcessingTextAreaComponent: Component { self.text = text self.loadingStateMeasuringText = loadingStateMeasuringText self.textCorrectionRanges = textCorrectionRanges + self.present = present + self.rootViewForTextSelection = rootViewForTextSelection } static func ==(lhs: TextProcessingTextAreaComponent, rhs: TextProcessingTextAreaComponent) -> Bool { @@ -60,6 +72,9 @@ final class TextProcessingTextAreaComponent: Component { if lhs.theme !== rhs.theme { return false } + if lhs.strings !== rhs.strings { + return false + } if lhs.titlePrefix != rhs.titlePrefix { return false } @@ -115,15 +130,29 @@ final class TextProcessingTextAreaComponent: Component { private let measureLoadingText = ComponentView() private var shimmerEffectNode: ShimmerEffectNode? + private var textSelectionNode: TextSelectionNode? + private let textSelectionContainer: UIView + private let textSelectionKnobContainer: UIView + private let textSelectionKnobSurface: UIView + + 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 @@ -459,6 +488,96 @@ final class TextProcessingTextAreaComponent: Component { } } + if component.text != nil, component.isExpanded?.value ?? true, let textView = self.text.view as? MultilineTextWithEntitiesComponent.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 { @@ -568,6 +687,7 @@ final class TextProcessingTextAreaComponent: Component { } transition.setFrame(view: self.textContainer, frame: textContainerFrame) + transition.setFrame(view: self.textSelectionContainer, frame: textContainerFrame) contentHeight += textContainerFrame.height contentHeight += bottomInset diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift index df326e7ade..6ec7fcfe68 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift @@ -83,6 +83,8 @@ final class TextProcessingTranslateContentComponent: Component { let mode: Mode let copyAction: (() -> Void)? let displayLanguageSelectionMenu: (UIView, String, TelegramComposeAIMessageMode.StyleId, Bool, @escaping (String, TelegramComposeAIMessageMode.StyleId) -> Void) -> Void + let present: (ViewController, Any?) -> Void + let rootViewForTextSelection: () -> UIView? init( context: AccountContext, @@ -93,7 +95,9 @@ final class TextProcessingTranslateContentComponent: Component { inputText: TextWithEntities, mode: Mode, copyAction: (() -> Void)?, - displayLanguageSelectionMenu: @escaping (UIView, String, TelegramComposeAIMessageMode.StyleId, Bool, @escaping (String, TelegramComposeAIMessageMode.StyleId) -> Void) -> Void + displayLanguageSelectionMenu: @escaping (UIView, String, TelegramComposeAIMessageMode.StyleId, Bool, @escaping (String, TelegramComposeAIMessageMode.StyleId) -> Void) -> Void, + present: @escaping (ViewController, Any?) -> Void, + rootViewForTextSelection: @escaping () -> UIView? ) { self.context = context self.theme = theme @@ -104,6 +108,8 @@ final class TextProcessingTranslateContentComponent: Component { self.mode = mode self.copyAction = copyAction self.displayLanguageSelectionMenu = displayLanguageSelectionMenu + self.present = present + self.rootViewForTextSelection = rootViewForTextSelection } static func ==(lhs: TextProcessingTranslateContentComponent, rhs: TextProcessingTranslateContentComponent) -> Bool { @@ -354,6 +360,7 @@ final class TextProcessingTranslateContentComponent: Component { component: AnyComponent(TextProcessingTextAreaComponent( context: component.context, theme: component.theme, + strings: component.strings, titlePrefix: fromPrefix, title: localizedLanguageName(strings: component.strings, language: component.externalState.sourceLanguage ?? ""), titleAction: nil, @@ -373,7 +380,9 @@ final class TextProcessingTranslateContentComponent: Component { emojify: nil, text: component.inputText, loadingStateMeasuringText: nil, - textCorrectionRanges: [] + textCorrectionRanges: [], + present: component.present, + rootViewForTextSelection: component.rootViewForTextSelection )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height) @@ -396,6 +405,7 @@ final class TextProcessingTranslateContentComponent: Component { component: AnyComponent(TextProcessingTextAreaComponent( context: component.context, theme: component.theme, + strings: component.strings, titlePrefix: toPrefix, title: toTitle, titleAction: component.mode == .translate ? { [weak self] sourceView in @@ -441,7 +451,9 @@ final class TextProcessingTranslateContentComponent: Component { ) : nil, text: component.externalState.result?.text, loadingStateMeasuringText: component.inputText.text, - textCorrectionRanges: component.mode == .fix ? (component.externalState.result?.textCorrectionRanges ?? []) : [] + textCorrectionRanges: component.mode == .fix ? (component.externalState.result?.textCorrectionRanges ?? []) : [], + present: component.present, + rootViewForTextSelection: component.rootViewForTextSelection )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height) diff --git a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift index f29ff1ef8a..a779d44732 100644 --- a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift +++ b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift @@ -216,15 +216,56 @@ public enum TextSelectionAction: Equatable { } public final class TextSelectionNode: ASDisplayNode { + public enum TextNodeOrView { + case node(TextNodeProtocol) + case view(TextView) + + var view: UIView { + switch self { + case let .node(node): + return node.view + case let .view(view): + return view + } + } + + var currentText: NSAttributedString? { + switch self { + case let .node(node): + return node.currentText + case let .view(view): + return view.cachedLayout?.attributedString + } + } + + func attributesAtPoint(_ point: CGPoint, orNearest: Bool = false) -> (Int, [NSAttributedString.Key: Any])? { + switch self { + case let .node(node): + return node.attributesAtPoint(point, orNearest: orNearest) + case let .view(view): + return view.attributesAtPoint(point, orNearest: orNearest) + } + } + + func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + switch self { + case let .node(node): + return node.textRangeRects(in: range) + case let .view(view): + return view.cachedLayout?.rangeRects(in: range) + } + } + } + private let theme: TextSelectionTheme private let strings: PresentationStrings - private let textNode: TextNodeProtocol + private let textNodeOrView: TextNodeOrView private let updateIsActive: (Bool) -> Void public var canBeginSelection: (CGPoint) -> Bool = { _ in true } public var updateRange: ((NSRange?) -> Void)? public var presentMenu: ((UIView, CGPoint, [ContextMenuAction]) -> Void)? private let present: (ViewController, Any?) -> Void - private let rootNode: () -> ASDisplayNode? + private let rootView: () -> UIView? private let performAction: (NSAttributedString, TextSelectionAction) -> Void private var highlightOverlay: LinkHighlightingNode? private let leftKnob: ASImageNode @@ -252,13 +293,13 @@ public final class TextSelectionNode: ASDisplayNode { private weak var contextMenu: ContextMenuController? - public init(theme: TextSelectionTheme, strings: PresentationStrings, textNode: TextNodeProtocol, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: @escaping () -> ASDisplayNode?, externalKnobSurface: UIView? = nil, performAction: @escaping (NSAttributedString, TextSelectionAction) -> Void) { + public init(theme: TextSelectionTheme, strings: PresentationStrings, textNodeOrView: TextNodeOrView, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootView: @escaping () -> UIView?, externalKnobSurface: UIView? = nil, performAction: @escaping (NSAttributedString, TextSelectionAction) -> Void) { self.theme = theme self.strings = strings - self.textNode = textNode + self.textNodeOrView = textNodeOrView self.updateIsActive = updateIsActive self.present = present - self.rootNode = rootNode + self.rootView = rootView self.performAction = performAction self.leftKnob = ASImageNode() self.leftKnob.isUserInteractionEnabled = false @@ -306,8 +347,8 @@ public final class TextSelectionNode: ASDisplayNode { return } - let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNode.view) - if let stringIndex = strongSelf.textNode.attributesAtPoint(mappedPoint, orNearest: true)?.0 { + let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNodeOrView.view) + if let stringIndex = strongSelf.textNodeOrView.attributesAtPoint(mappedPoint, orNearest: true)?.0 { var updatedLeft = currentRange.0 var updatedRight = currentRange.1 switch knob { @@ -335,15 +376,15 @@ public final class TextSelectionNode: ASDisplayNode { strongSelf.displayMenu() } recognizer.beginSelection = { [weak self] point in - guard let strongSelf = self, let attributedString = strongSelf.textNode.currentText else { + guard let strongSelf = self, let attributedString = strongSelf.textNodeOrView.currentText else { return } strongSelf.dismissSelection() - let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNode.view) + let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNodeOrView.view) var resultRange: NSRange? - if let stringIndex = strongSelf.textNode.attributesAtPoint(mappedPoint, orNearest: false)?.0 { + if let stringIndex = strongSelf.textNodeOrView.attributesAtPoint(mappedPoint, orNearest: false)?.0 { let string = attributedString.string as NSString let inputRange = CFRangeMake(0, string.length) @@ -398,7 +439,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func pretendInitiateSelection() { - guard let attributedString = self.textNode.currentText else { + guard let attributedString = self.textNodeOrView.currentText else { return } @@ -432,7 +473,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func pretendExtendSelection(to index: Int) { - guard let endRangeRect = self.textNode.textRangeRects(in: NSRange(location: index, length: 1))?.rects.first else { + guard let endRangeRect = self.textNodeOrView.textRangeRects(in: NSRange(location: index, length: 1))?.rects.first else { return } let startPoint = self.rightKnob.frame.center @@ -449,7 +490,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func setSelection(range: NSRange, displayMenu: Bool) { - guard let attributedString = self.textNode.currentText else { + guard let attributedString = self.textNodeOrView.currentText else { return } let range = self.convertSelectionFromOriginalText(attributedString: attributedString, range: range) @@ -561,7 +602,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func getSelection() -> NSRange? { - guard let currentRange = self.currentRange, let attributedString = self.textNode.currentText else { + guard let currentRange = self.currentRange, let attributedString = self.textNodeOrView.currentText else { return nil } let range = NSRange(location: min(currentRange.0, currentRange.1), length: max(currentRange.0, currentRange.1) - min(currentRange.0, currentRange.1)) @@ -574,7 +615,7 @@ public final class TextSelectionNode: ASDisplayNode { var rects: (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? if let range { - if var rectsValue = self.textNode.textRangeRects(in: range) { + if var rectsValue = self.textNodeOrView.textRangeRects(in: range) { var rectList = rectsValue.rects if rectList.count > 1 { for i in 0 ..< rectList.count - 1 { @@ -683,7 +724,7 @@ public final class TextSelectionNode: ASDisplayNode { } private func displayMenu() { - guard let currentRects = self.currentRects, !currentRects.isEmpty, let currentRange = self.currentRange, let attributedString = self.textNode.currentText else { + guard let currentRects = self.currentRects, !currentRects.isEmpty, let currentRange = self.currentRange, let attributedString = self.textNodeOrView.currentText else { return } let range = NSRange(location: min(currentRange.0, currentRange.1), length: max(currentRange.0, currentRange.1) - min(currentRange.0, currentRange.1)) @@ -778,15 +819,15 @@ public final class TextSelectionNode: ASDisplayNode { return true } self.contextMenu = contextMenu - self.present(contextMenu, ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in - guard let strongSelf = self, let rootNode = strongSelf.rootNode() else { + self.present(contextMenu, ContextMenuControllerPresentationArguments(sourceViewAndRect: { [weak self] in + guard let strongSelf = self, let rootView = strongSelf.rootView() else { return nil } if strongSelf.menuSkipCoordnateConversion { - return (strongSelf, strongSelf.view.convert(completeRect, to: rootNode.view), rootNode, rootNode.bounds) + return (strongSelf.view, strongSelf.view.convert(completeRect, to: rootView), rootView, rootView.bounds) } else { - return (strongSelf, completeRect, rootNode, rootNode.bounds) + return (strongSelf.view, completeRect, rootView, rootView.bounds) } }, bounce: false)) }