diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD index 08e4256ed3..8a1b1d32a3 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -79,6 +79,11 @@ swift_library( "//submodules/Utils/RangeSet", "//submodules/TelegramUI/Components/Stories/StoryStealthModeSheetScreen", "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/TextSelectionNode", + "//submodules/Pasteboard", + "//submodules/Speak", + "//submodules/TranslateUI", + "//submodules/TelegramNotices", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift index 513ceb41ee..08ee615ac4 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift @@ -10,6 +10,7 @@ import TextFormat import InvisibleInkDustNode import UrlEscaping import TelegramPresentationData +import TextSelectionNode final class StoryContentCaptionComponent: Component { enum Action { @@ -23,6 +24,7 @@ final class StoryContentCaptionComponent: Component { final class ExternalState { fileprivate(set) var isExpanded: Bool = false + fileprivate(set) var isSelectingText: Bool = false init() { } @@ -51,30 +53,39 @@ final class StoryContentCaptionComponent: Component { let externalState: ExternalState let context: AccountContext let strings: PresentationStrings + let theme: PresentationTheme let text: String let entities: [MessageTextEntity] let entityFiles: [EngineMedia.Id: TelegramMediaFile] let action: (Action) -> Void let longTapAction: (Action) -> Void + let textSelectionAction: (NSAttributedString, TextSelectionAction) -> Void + let controller: () -> ViewController? init( externalState: ExternalState, context: AccountContext, strings: PresentationStrings, + theme: PresentationTheme, text: String, entities: [MessageTextEntity], entityFiles: [EngineMedia.Id: TelegramMediaFile], action: @escaping (Action) -> Void, - longTapAction: @escaping (Action) -> Void + longTapAction: @escaping (Action) -> Void, + textSelectionAction: @escaping (NSAttributedString, TextSelectionAction) -> Void, + controller: @escaping () -> ViewController? ) { self.externalState = externalState self.context = context self.strings = strings + self.theme = theme self.text = text self.entities = entities self.entityFiles = entityFiles self.action = action self.longTapAction = longTapAction + self.textSelectionAction = textSelectionAction + self.controller = controller } static func ==(lhs: StoryContentCaptionComponent, rhs: StoryContentCaptionComponent) -> Bool { @@ -87,6 +98,9 @@ final class StoryContentCaptionComponent: Component { if lhs.strings !== rhs.strings { return false } + if lhs.theme !== rhs.theme { + return false + } if lhs.text != rhs.text { return false } @@ -136,6 +150,7 @@ final class StoryContentCaptionComponent: Component { private let collapsedText: ContentItem private let expandedText: ContentItem + private var textSelectionNode: TextSelectionNode? private let scrollMaskContainer: UIView private let scrollFullMaskView: UIView @@ -217,9 +232,6 @@ final class StoryContentCaptionComponent: Component { self.scrollView.addSubview(self.expandedText) self.scrollViewContainer.mask = self.scrollMaskContainer - - let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) - self.addGestureRecognizer(tapRecognizer) } required init?(coder: NSCoder) { @@ -231,10 +243,12 @@ final class StoryContentCaptionComponent: Component { return nil } - if let textView = self.collapsedText.textNode?.textNode.view { + let contentItem = self.isExpanded ? self.expandedText : self.collapsedText + + if let textView = contentItem.textNode?.textNode.view { let textLocalPoint = self.convert(point, to: textView) if textLocalPoint.y >= -7.0 { - return textView + return self.textSelectionNode?.view ?? textView } } @@ -251,6 +265,15 @@ final class StoryContentCaptionComponent: Component { } } + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + guard let component = self.component else { + return + } + if component.externalState.isSelectingText { + self.cancelTextSelection() + } + } + func scrollViewDidScroll(_ scrollView: UIScrollView) { if !self.ignoreScrolling { self.updateScrolling(transition: .immediate) @@ -292,6 +315,10 @@ final class StoryContentCaptionComponent: Component { self.updateScrolling(transition: transition.withUserData(InternalTransitionHint(bounceScrolling: true))) } + func cancelTextSelection() { + self.textSelectionNode?.cancelSelection() + } + private func updateScrolling(transition: Transition) { guard let component = self.component, let itemLayout = self.itemLayout else { return @@ -340,6 +367,12 @@ final class StoryContentCaptionComponent: Component { } @objc func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { + if let textSelectionNode = self.textSelectionNode { + if textSelectionNode.didRecognizeTap { + return + } + } + let contentItem = self.isExpanded ? self.expandedText : self.collapsedText let otherContentItem = !self.isExpanded ? self.expandedText : self.collapsedText @@ -386,7 +419,9 @@ final class StoryContentCaptionComponent: Component { } } else { if case .tap = gesture { - if self.isExpanded { + if component.externalState.isSelectingText { + self.cancelTextSelection() + } else if self.isExpanded { self.collapse(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) } else { self.expand(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) @@ -395,7 +430,9 @@ final class StoryContentCaptionComponent: Component { } } else { if case .tap = gesture { - if self.isExpanded { + if component.externalState.isSelectingText { + self.cancelTextSelection() + } else if self.isExpanded { self.collapse(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) } else { self.expand(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) @@ -545,18 +582,6 @@ final class StoryContentCaptionComponent: Component { self.collapsedText.textNode = collapsedTextNode if collapsedTextNode.textNode.view.superview == nil { self.collapsedText.addSubview(collapsedTextNode.textNode.view) - - let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) - recognizer.tapActionAtPoint = { point in - return .waitForSingleTap - } - recognizer.highlight = { [weak self] point in - guard let self else { - return - } - self.updateTouchesAtPoint(point) - } - collapsedTextNode.textNode.view.addGestureRecognizer(recognizer) } collapsedTextNode.visibilityRect = CGRect(origin: CGPoint(), size: CGSize(width: 100000.0, height: 100000.0)) @@ -623,18 +648,6 @@ final class StoryContentCaptionComponent: Component { self.expandedText.textNode = expandedTextNode if expandedTextNode.textNode.view.superview == nil { self.expandedText.addSubview(expandedTextNode.textNode.view) - - let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) - recognizer.tapActionAtPoint = { point in - return .waitForSingleTap - } - recognizer.highlight = { [weak self] point in - guard let self else { - return - } - self.updateTouchesAtPoint(point) - } - expandedTextNode.textNode.view.addGestureRecognizer(recognizer) } expandedTextNode.visibilityRect = CGRect(origin: CGPoint(), size: CGSize(width: 100000.0, height: 100000.0)) @@ -687,6 +700,114 @@ final class StoryContentCaptionComponent: Component { } } + if self.textSelectionNode == nil, let controller = component.controller(), let textNode = self.expandedText.textNode?.textNode { + let selectionColor = UIColor(white: 1.0, alpha: 0.5) + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: component.theme.list.itemAccentColor), strings: component.strings, textNode: textNode, updateIsActive: { [weak self] value in + guard let self else { + return + } + if component.externalState.isSelectingText != value { + component.externalState.isSelectingText = value + + if !self.ignoreExternalState { + self.state?.updated(transition: transition) + } + } + }, present: { [weak self] c, a in + guard let self, let component = self.component else { + return + } + component.controller()?.presentInGlobalOverlay(c, with: a) + }, rootNode: controller.displayNode, performAction: { [weak self] text, action in + guard let self, let component = self.component else { + return + } + component.textSelectionAction(text, action) + }) + /*textSelectionNode.updateRange = { [weak self] selectionRange in + if let strongSelf = self, let dustNode = strongSelf.dustNode, !dustNode.isRevealed, let textLayout = strongSelf.textNode.textNode.cachedLayout, !textLayout.spoilers.isEmpty, let selectionRange = selectionRange { + for (spoilerRange, _) in textLayout.spoilers { + if let intersection = selectionRange.intersection(spoilerRange), intersection.length > 0 { + dustNode.update(revealed: true) + return + } + } + } + }*/ + textSelectionNode.enableLookup = true + self.textSelectionNode = textSelectionNode + self.scrollView.addSubview(textSelectionNode.view) + self.scrollView.insertSubview(textSelectionNode.highlightAreaNode.view, at: 0) + + textSelectionNode.canBeginSelection = { [weak self] location in + guard let self else { + return false + } + + let contentItem = self.expandedText + guard let textNode = contentItem.textNode else { + return false + } + + let titleFrame = textNode.textNode.view.bounds + + if let (index, attributes) = textNode.textNode.attributesAtPoint(CGPoint(x: location.x - titleFrame.minX, y: location.y - titleFrame.minY)) { + let action: Action? + if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)], !(contentItem.dustNode?.isRevealed ?? true) { + return false + } else if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { + var concealed = true + if let (attributeText, fullText) = textNode.textNode.attributeSubstring(name: TelegramTextAttributes.URL, index: index) { + concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText) + } + action = .url(url: url, concealed: concealed) + } else if let peerMention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention { + action = .peerMention(peerId: peerMention.peerId, mention: peerMention.mention) + } else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { + action = .textMention(peerName) + } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { + action = .hashtag(hashtag.peerName, hashtag.hashtag) + } else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String { + action = .bankCard(bankCard) + } else if let emoji = attributes[NSAttributedString.Key(rawValue: ChatTextInputAttributes.customEmoji.rawValue)] as? ChatTextInputTextCustomEmojiAttribute, let file = emoji.file { + action = .customEmoji(file) + } else { + action = nil + } + if action != nil { + return false + } + } + + return true + } + + //let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + //textSelectionNode.view.addGestureRecognizer(tapRecognizer) + + let _ = textSelectionNode.view + + let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) + /*if let selectionRecognizer = textSelectionNode.recognizer { + recognizer.require(toFail: selectionRecognizer) + }*/ + recognizer.tapActionAtPoint = { point in + return .waitForSingleTap + } + recognizer.highlight = { [weak self] point in + guard let self else { + return + } + self.updateTouchesAtPoint(point) + } + textSelectionNode.view.addGestureRecognizer(recognizer) + } + + if let textSelectionNode = self.textSelectionNode, let textNode = self.expandedText.textNode?.textNode { + textSelectionNode.frame = textNode.frame.offsetBy(dx: self.expandedText.frame.minX, dy: self.expandedText.frame.minY) + textSelectionNode.highlightAreaNode.frame = textSelectionNode.frame + } + self.itemLayout = ItemLayout( containerSize: availableSize, visibleTextHeight: visibleTextHeight, diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 53d3fab91c..2411b584e6 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -36,6 +36,8 @@ import StickerPackPreviewUI import TextNodeWithEntities import TelegramStringFormatting import LottieComponent +import Pasteboard +import Speak public final class StoryAvailableReactions: Equatable { let reactionItems: [ReactionItem] @@ -765,9 +767,13 @@ public final class StoryItemSetContainerComponent: Component { break } } - } else if let captionItem = self.captionItem, captionItem.externalState.isExpanded { + } else if let captionItem = self.captionItem, (captionItem.externalState.isExpanded || captionItem.externalState.isSelectingText) { if let captionItemView = captionItem.view.view as? StoryContentCaptionComponent.View { - captionItemView.collapse(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + if captionItem.externalState.isSelectingText { + captionItemView.cancelTextSelection() + } else { + captionItemView.collapse(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } } } else { let point = recognizer.location(in: self) @@ -967,13 +973,16 @@ public final class StoryItemSetContainerComponent: Component { if self.sendMessageContext.statusController != nil { return .pause } + if self.sendMessageContext.lookupController != nil { + return .pause + } if let navigationController = component.controller()?.navigationController as? NavigationController { let topViewController = navigationController.topViewController if !(topViewController is StoryContainerScreen) && !(topViewController is MediaEditorScreen) && !(topViewController is ShareWithPeersScreen) && !(topViewController is AttachmentController) { return .pause } } - if let captionItem = self.captionItem, captionItem.externalState.isExpanded { + if let captionItem = self.captionItem, captionItem.externalState.isExpanded || captionItem.externalState.isSelectingText { return .blurred } return .play @@ -2369,36 +2378,29 @@ public final class StoryItemSetContainerComponent: Component { let moreButtonSize = self.moreButton.update( transition: transition, - component: AnyComponent(MessageInputActionButtonComponent( - mode: .more, - action: { _, _, _ in - }, - longPressAction: nil, - switchMediaInputMode: { - }, - updateMediaCancelFraction: { _ in - }, - lockMediaRecording: { - }, - stopAndPreviewMediaRecording: { - }, - moreAction: { [weak self] view, gesture in + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent( + name: "anim_story_more" + ), + color: .white, + startingPosition: .end, + size: CGSize(width: 30.0, height: 30.0) + )), + effectAlignment: .center, + minSize: CGSize(width: 33.0, height: 64.0), + action: { [weak self] in guard let self else { return } - self.performMoreAction(sourceView: view, gesture: gesture) - }, - context: component.context, - theme: component.theme, - strings: component.strings, - presentController: { [weak self] c in - guard let self, let component = self.component else { + guard let moreButtonView = self.moreButton.view else { return } - component.presentController(c, nil) - }, - audioRecorder: nil, - videoRecordingStatus: nil + if let animationView = (moreButtonView as? PlainButtonComponent.View)?.contentView as? LottieComponent.View { + animationView.playOnce() + } + self.performMoreAction(sourceView: moreButtonView, gesture: nil) + } )), environment: {}, containerSize: CGSize(width: 33.0, height: 64.0) @@ -2410,7 +2412,7 @@ public final class StoryItemSetContainerComponent: Component { moreButtonView.isUserInteractionEnabled = !component.slice.item.storyItem.isPending transition.setFrame(view: moreButtonView, frame: CGRect(origin: CGPoint(x: headerRightOffset - moreButtonSize.width, y: 2.0), size: moreButtonSize)) transition.setAlpha(view: moreButtonView, alpha: component.slice.item.storyItem.isPending ? 0.5 : 1.0) - headerRightOffset -= moreButtonSize.width + 15.0 + headerRightOffset -= moreButtonSize.width + 12.0 } var isSilentVideo = false @@ -2762,6 +2764,7 @@ public final class StoryItemSetContainerComponent: Component { externalState: captionItem.externalState, context: component.context, strings: component.strings, + theme: component.theme, text: component.slice.item.storyItem.text, entities: component.slice.item.storyItem.entities, entityFiles: component.slice.item.entityFiles, @@ -2811,6 +2814,39 @@ public final class StoryItemSetContainerComponent: Component { self.sendMessageContext.openResolved(view: self, result: resolved, forceExternal: false, concealed: concealed) }) }) + }, + textSelectionAction: { [weak self] text, action in + guard let self, let component = self.component else { + return + } + switch action { + case .copy: + storeAttributedTextInPasteboard(text) + case .share: + self.sendMessageContext.performShareTextAction(view: self, text: text.string) + case .lookup: + self.sendMessageContext.performLookupTextAction(view: self, text: text.string) + 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.sendMessageContext.currentSpeechHolder === speechHolder { + self.sendMessageContext.currentSpeechHolder = nil + } + } + self.sendMessageContext.currentSpeechHolder = speechHolder + } + case .translate: + self.sendMessageContext.performTranslateTextAction(view: self, text: text.string) + } + }, + controller: { [weak self] in + guard let self, let component = self.component else { + return nil + } + return component.controller() } )), environment: {}, diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift index 133d01b8f7..65907d5769 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift @@ -43,6 +43,12 @@ import WebPBinding import ContextUI import ChatScheduleTimeController import StoryStealthModeSheetScreen +import Speak +import TranslateUI +import TelegramNotices +import ObjectiveC + +private var ObjCKey_DeinitWatcher: Int? final class StoryItemSetContainerSendMessage { enum InputMode { @@ -59,6 +65,7 @@ final class StoryItemSetContainerSendMessage { weak var tooltipScreen: ViewController? weak var actionSheet: ViewController? weak var statusController: ViewController? + weak var lookupController: UIViewController? var isViewingAttachedStickers = false var currentTooltipUpdateTimer: Foundation.Timer? @@ -86,6 +93,8 @@ final class StoryItemSetContainerSendMessage { let navigationActionDisposable = MetaDisposable() let resolvePeerByNameDisposable = MetaDisposable() + var currentSpeechHolder: SpeechSynthesizerHolder? + private(set) var isMediaRecordingLocked: Bool = false var wasRecordingDismissed: Bool = false @@ -1016,6 +1025,129 @@ final class StoryItemSetContainerSendMessage { } } + func performShareTextAction(view: StoryItemSetContainerComponent.View, text: String) { + guard let component = view.component else { + return + } + guard let controller = component.controller() else { + return + } + + let theme = component.theme + let updatedPresentationData: (initial: PresentationData, signal: Signal) = (component.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: theme), component.context.sharedContext.presentationData |> map { $0.withUpdated(theme: theme) }) + + let shareController = ShareController(context: component.context, subject: .text(text), externalShare: true, immediateExternalShare: false, updatedPresentationData: updatedPresentationData) + + self.shareController = shareController + view.updateIsProgressPaused() + + shareController.dismissed = { [weak self, weak view] _ in + guard let self, let view else { + return + } + self.shareController = nil + view.updateIsProgressPaused() + } + + controller.present(shareController, in: .window(.root)) + } + + func performTranslateTextAction(view: StoryItemSetContainerComponent.View, text: String) { + guard let component = view.component else { + return + } + + let _ = (component.context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.translationSettings]) + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self, weak view] sharedData in + guard let self, let view else { + return + } + let peer = component.slice.peer + + let _ = self + + let translationSettings: TranslationSettings + if let current = sharedData.entries[ApplicationSpecificSharedDataKeys.translationSettings]?.get(TranslationSettings.self) { + translationSettings = current + } else { + translationSettings = TranslationSettings.defaultSettings + } + + var showTranslateIfTopical = false + if case let .channel(channel) = peer, !(channel.addressName ?? "").isEmpty { + showTranslateIfTopical = true + } + + let (_, language) = canTranslateText(context: component.context, text: text, showTranslate: translationSettings.showTranslate, showTranslateIfTopical: showTranslateIfTopical, ignoredLanguages: translationSettings.ignoredLanguages) + + let _ = ApplicationSpecificNotice.incrementTranslationSuggestion(accountManager: component.context.sharedContext.accountManager, timestamp: Int32(Date().timeIntervalSince1970)).start() + + let translateController = TranslateScreen(context: component.context, text: text, canCopy: true, fromLanguage: language) + translateController.pushController = { [weak view] c in + guard let view, let component = view.component else { + return + } + component.controller()?.push(c) + } + translateController.presentController = { [weak view] c in + guard let view, let component = view.component else { + return + } + component.controller()?.present(c, in: .window(.root)) + } + + self.actionSheet = translateController + view.updateIsProgressPaused() + + translateController.wasDismissed = { [weak self, weak view] in + guard let self, let view else { + return + } + self.actionSheet = nil + view.updateIsProgressPaused() + } + + component.controller()?.present(translateController, in: .window(.root)) + }) + } + + func performLookupTextAction(view: StoryItemSetContainerComponent.View, text: String) { + guard let component = view.component else { + return + } + let controller = UIReferenceLibraryViewController(term: text) + if let window = component.controller()?.view.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) + + final class DeinitWatcher: NSObject { + let f: () -> Void + + init(_ f: @escaping () -> Void) { + self.f = f + } + + deinit { + f() + } + } + + self.lookupController = controller + view.updateIsProgressPaused() + + objc_setAssociatedObject(controller, &ObjCKey_DeinitWatcher, DeinitWatcher { [weak self, weak view] in + guard let self, let view else { + return + } + + self.lookupController = nil + view.updateIsProgressPaused() + }, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + func performCopyLinkAction(view: StoryItemSetContainerComponent.View) { guard let component = view.component else { return diff --git a/submodules/TelegramUI/Resources/Animations/anim_story_more.tgs b/submodules/TelegramUI/Resources/Animations/anim_story_more.tgs new file mode 100644 index 0000000000..282e6a31ba Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/anim_story_more.tgs differ diff --git a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift index b54f9eb253..f0f39aa6ae 100644 --- a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift +++ b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift @@ -74,18 +74,21 @@ private enum Knob { case right } -private final class TextSelectionGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate { +public final class TextSelectionGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate { private var longTapTimer: Timer? private var movingKnob: (Knob, CGPoint, CGPoint)? private var currentLocation: CGPoint? - var beginSelection: ((CGPoint) -> Void)? - var knobAtPoint: ((CGPoint) -> (Knob, CGPoint)?)? - var moveKnob: ((Knob, CGPoint) -> Void)? - var finishedMovingKnob: (() -> Void)? - var clearSelection: (() -> Void)? + public var canBeginSelection: ((CGPoint) -> Bool)? + public var beginSelection: ((CGPoint) -> Void)? + fileprivate var knobAtPoint: ((CGPoint) -> (Knob, CGPoint)?)? + fileprivate var moveKnob: ((Knob, CGPoint) -> Void)? + public var finishedMovingKnob: (() -> Void)? + public var clearSelection: (() -> Void)? + public private(set) var didRecognizeTap: Bool = false + fileprivate var isSelecting: Bool = false - override init(target: Any?, action: Selector?) { + override public init(target: Any?, action: Selector?) { super.init(target: nil, action: nil) self.delegate = self @@ -101,7 +104,7 @@ private final class TextSelectionGestureRecognizer: UIGestureRecognizer, UIGestu self.currentLocation = nil } - override func touchesBegan(_ touches: Set, with event: UIEvent) { + override public func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) let currentLocation = touches.first?.location(in: self.view) @@ -112,28 +115,32 @@ private final class TextSelectionGestureRecognizer: UIGestureRecognizer, UIGestu self.movingKnob = (knob, knobPosition, currentLocation) cancelScrollViewGestures(view: self.view?.superview) self.state = .began - } else if self.longTapTimer == nil { - final class TimerTarget: NSObject { - let f: () -> Void - - init(_ f: @escaping () -> Void) { - self.f = f - } - - @objc func event() { - self.f() + } else if self.canBeginSelection?(currentLocation) ?? true { + if self.longTapTimer == nil { + final class TimerTarget: NSObject { + let f: () -> Void + + init(_ f: @escaping () -> Void) { + self.f = f + } + + @objc func event() { + self.f() + } } + let longTapTimer = Timer(timeInterval: 0.3, target: TimerTarget({ [weak self] in + self?.longTapEvent() + }), selector: #selector(TimerTarget.event), userInfo: nil, repeats: false) + self.longTapTimer = longTapTimer + RunLoop.main.add(longTapTimer, forMode: .common) } - let longTapTimer = Timer(timeInterval: 0.3, target: TimerTarget({ [weak self] in - self?.longTapEvent() - }), selector: #selector(TimerTarget.event), userInfo: nil, repeats: false) - self.longTapTimer = longTapTimer - RunLoop.main.add(longTapTimer, forMode: .common) + } else { + self.state = .failed } } } - override func touchesMoved(_ touches: Set, with event: UIEvent) { + override public func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) let currentLocation = touches.first?.location(in: self.view) @@ -144,12 +151,20 @@ private final class TextSelectionGestureRecognizer: UIGestureRecognizer, UIGestu } } - override func touchesEnded(_ touches: Set, with event: UIEvent) { + override public func touchesEnded(_ touches: Set, with event: UIEvent) { super.touchesEnded(touches, with: event) if let longTapTimer = self.longTapTimer { self.longTapTimer = nil longTapTimer.invalidate() + + if self.isSelecting { + self.didRecognizeTap = true + DispatchQueue.main.async { [weak self] in + self?.didRecognizeTap = false + } + } + self.clearSelection?() } else { if let _ = self.currentLocation, let _ = self.movingKnob { @@ -159,7 +174,7 @@ private final class TextSelectionGestureRecognizer: UIGestureRecognizer, UIGestu self.state = .ended } - override func touchesCancelled(_ touches: Set, with event: UIEvent) { + override public func touchesCancelled(_ touches: Set, with event: UIEvent) { super.touchesCancelled(touches, with: event) self.state = .cancelled @@ -172,12 +187,11 @@ private final class TextSelectionGestureRecognizer: UIGestureRecognizer, UIGestu } } - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { return true } - @available(iOS 9.0, *) - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive press: UIPress) -> Bool { + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive press: UIPress) -> Bool { return true } } @@ -203,6 +217,7 @@ public final class TextSelectionNode: ASDisplayNode { private let strings: PresentationStrings private let textNode: TextNode private let updateIsActive: (Bool) -> Void + public var canBeginSelection: (CGPoint) -> Bool = { _ in true } public var updateRange: ((NSRange?) -> Void)? private let present: (ViewController, Any?) -> Void private weak var rootNode: ASDisplayNode? @@ -216,9 +231,15 @@ public final class TextSelectionNode: ASDisplayNode { public let highlightAreaNode: ASDisplayNode - private var recognizer: TextSelectionGestureRecognizer? + public private(set) var recognizer: TextSelectionGestureRecognizer? private var displayLinkAnimator: DisplayLinkAnimator? + public var enableLookup: Bool = true + + public var didRecognizeTap: Bool { + return self.recognizer?.didRecognizeTap ?? false + } + public init(theme: TextSelectionTheme, strings: PresentationStrings, textNode: TextNode, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: ASDisplayNode, performAction: @escaping (NSAttributedString, TextSelectionAction) -> Void) { self.theme = theme self.strings = strings @@ -332,12 +353,19 @@ public final class TextSelectionNode: ASDisplayNode { } strongSelf.updateSelection(range: resultRange, animateIn: true) strongSelf.displayMenu() + strongSelf.recognizer?.isSelecting = true strongSelf.updateIsActive(true) } recognizer.clearSelection = { [weak self] in self?.dismissSelection() self?.updateIsActive(false) } + recognizer.canBeginSelection = { [weak self] point in + guard let self else { + return false + } + return self.canBeginSelection(point) + } self.recognizer = recognizer self.view.addGestureRecognizer(recognizer) } @@ -487,9 +515,15 @@ public final class TextSelectionNode: ASDisplayNode { private func dismissSelection() { self.currentRange = nil + self.recognizer?.isSelecting = false self.updateSelection(range: nil, animateIn: false) } + public func cancelSelection() { + self.dismissSelection() + self.updateIsActive(false) + } + private func displayMenu() { guard let currentRects = self.currentRects, !currentRects.isEmpty, let currentRange = self.currentRange, let cachedLayout = self.textNode.cachedLayout, let attributedString = cachedLayout.attributedString else { return @@ -529,16 +563,18 @@ public final class TextSelectionNode: ASDisplayNode { var actions: [ContextMenuAction] = [] actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: { [weak self] in self?.performAction(string, .copy) - self?.dismissSelection() - })) - actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuLookUp, accessibilityLabel: self.strings.Conversation_ContextMenuLookUp), action: { [weak self] in - self?.performAction(string, .lookup) - self?.dismissSelection() + self?.cancelSelection() })) + if self.enableLookup { + actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuLookUp, accessibilityLabel: self.strings.Conversation_ContextMenuLookUp), action: { [weak self] in + self?.performAction(string, .lookup) + self?.cancelSelection() + })) + } if #available(iOS 15.0, *) { actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuTranslate, accessibilityLabel: self.strings.Conversation_ContextMenuTranslate), action: { [weak self] in self?.performAction(string, .translate) - self?.dismissSelection() + self?.cancelSelection() })) } // if isSpeakSelectionEnabled() { @@ -549,7 +585,7 @@ public final class TextSelectionNode: ASDisplayNode { // } actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuShare, accessibilityLabel: self.strings.Conversation_ContextMenuShare), action: { [weak self] in self?.performAction(string, .share) - self?.dismissSelection() + self?.cancelSelection() })) self.present(ContextMenuController(actions: actions, catchTapsOutside: false, hasHapticFeedback: false), ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in diff --git a/submodules/TranslateUI/Sources/TranslateScreen.swift b/submodules/TranslateUI/Sources/TranslateScreen.swift index a42435f60e..f34a6f60a5 100644 --- a/submodules/TranslateUI/Sources/TranslateScreen.swift +++ b/submodules/TranslateUI/Sources/TranslateScreen.swift @@ -990,6 +990,8 @@ public class TranslateScreen: ViewController { public var pushController: (ViewController) -> Void = { _ in } public var presentController: (ViewController) -> Void = { _ in } + public var wasDismissed: (() -> Void)? + public convenience init(context: AccountContext, text: String, canCopy: Bool, fromLanguage: String?, toLanguage: String? = nil, isExpanded: Bool = false) { let presentationData = context.sharedContext.currentPresentationData.with { $0 } @@ -1086,13 +1088,16 @@ public class TranslateScreen: ViewController { public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { self.view.endEditing(true) + let wasDismissed = self.wasDismissed if flag { self.node.animateOut(completion: { super.dismiss(animated: false, completion: {}) + wasDismissed?() completion?() }) } else { super.dismiss(animated: false, completion: {}) + wasDismissed?() completion?() } }