From 5a6963b12538f4c814390e782a5e5f350f55eba6 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 24 Apr 2020 17:09:20 +0400 Subject: [PATCH] Preserve text formatting when copying a part of message text --- .../TelegramUI/Sources/ChatController.swift | 6 +-- .../Sources/ChatControllerInteraction.swift | 4 +- .../TelegramUI/Sources/Pasteboard.swift | 46 +++++++++++-------- .../Sources/TextSelectionNode.swift | 14 +++--- 4 files changed, 40 insertions(+), 30 deletions(-) diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 4c138d6eba..d6f7f2bff8 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -1874,13 +1874,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } switch action { case .copy: - UIPasteboard.general.string = text + storeAttributedTextInPasteboard(text) case .share: let f = { guard let strongSelf = self else { return } - let shareController = ShareController(context: strongSelf.context, subject: .text(text), externalShare: true, immediateExternalShare: false) + let shareController = ShareController(context: strongSelf.context, subject: .text(text.string), externalShare: true, immediateExternalShare: false) strongSelf.chatDisplayNode.dismissInput() strongSelf.present(shareController, in: .window(.root)) } @@ -1892,7 +1892,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G f() } case .lookup: - let controller = UIReferenceLibraryViewController(term: text) + let controller = UIReferenceLibraryViewController(term: text.string) if let window = strongSelf.effectiveNavigationController?.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)) diff --git a/submodules/TelegramUI/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Sources/ChatControllerInteraction.swift index e7ecfe2061..0fe1ae78b3 100644 --- a/submodules/TelegramUI/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Sources/ChatControllerInteraction.swift @@ -100,7 +100,7 @@ public final class ChatControllerInteraction { let scheduleCurrentMessage: () -> Void let sendScheduledMessagesNow: ([MessageId]) -> Void let editScheduledMessagesTime: ([MessageId]) -> Void - let performTextSelectionAction: (UInt32, String, TextSelectionAction) -> Void + let performTextSelectionAction: (UInt32, NSAttributedString, TextSelectionAction) -> Void let updateMessageReaction: (MessageId, String?) -> Void let openMessageReactions: (MessageId) -> Void let displaySwipeToReplyHint: () -> Void @@ -126,7 +126,7 @@ public final class ChatControllerInteraction { var searchTextHighightState: (String, [MessageIndex])? var seenOneTimeAnimatedMedia = Set() - init(openMessage: @escaping (Message, ChatControllerInteractionOpenMessageMode) -> Bool, openPeer: @escaping (PeerId?, ChatControllerInteractionNavigateToPeer, Message?) -> Void, openPeerMention: @escaping (String) -> Void, openMessageContextMenu: @escaping (Message, Bool, ASDisplayNode, CGRect, UIGestureRecognizer?) -> Void, openMessageContextActions: @escaping (Message, ASDisplayNode, CGRect, ContextGesture?) -> Void, navigateToMessage: @escaping (MessageId, MessageId) -> Void, tapMessage: ((Message) -> Void)?, clickThroughMessage: @escaping () -> Void, toggleMessagesSelection: @escaping ([MessageId], Bool) -> Void, sendCurrentMessage: @escaping (Bool) -> Void, sendMessage: @escaping (String) -> Void, sendSticker: @escaping (FileMediaReference, Bool, ASDisplayNode, CGRect) -> Bool, sendGif: @escaping (FileMediaReference, ASDisplayNode, CGRect) -> Bool, requestMessageActionCallback: @escaping (MessageId, MemoryBuffer?, Bool) -> Void, requestMessageActionUrlAuth: @escaping (String, MessageId, Int32) -> Void, activateSwitchInline: @escaping (PeerId?, String) -> Void, openUrl: @escaping (String, Bool, Bool?, Message?) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId?, String) -> Void, openInstantPage: @escaping (Message, ChatMessageItemAssociatedData?) -> Void, openWallpaper: @escaping (Message) -> Void, openTheme: @escaping (Message) -> Void, openHashtag: @escaping (String?, String) -> Void, updateInputState: @escaping ((ChatTextInputState) -> ChatTextInputState) -> Void, updateInputMode: @escaping ((ChatInputMode) -> ChatInputMode) -> Void, openMessageShareMenu: @escaping (MessageId) -> Void, presentController: @escaping (ViewController, Any?) -> Void, navigationController: @escaping () -> NavigationController?, chatControllerNode: @escaping () -> ASDisplayNode?, reactionContainerNode: @escaping () -> ReactionSelectionParentNode?, presentGlobalOverlayController: @escaping (ViewController, Any?) -> Void, callPeer: @escaping (PeerId) -> Void, longTap: @escaping (ChatControllerInteractionLongTapAction, Message?) -> Void, openCheckoutOrReceipt: @escaping (MessageId) -> Void, openSearch: @escaping () -> Void, setupReply: @escaping (MessageId) -> Void, canSetupReply: @escaping (Message) -> Bool, navigateToFirstDateMessage: @escaping(Int32) ->Void, requestRedeliveryOfFailedMessages: @escaping (MessageId) -> Void, addContact: @escaping (String) -> Void, rateCall: @escaping (Message, CallId) -> Void, requestSelectMessagePollOptions: @escaping (MessageId, [Data]) -> Void, requestOpenMessagePollResults: @escaping (MessageId, MediaId) -> Void, openAppStorePage: @escaping () -> Void, displayMessageTooltip: @escaping (MessageId, String, ASDisplayNode?, CGRect?) -> Void, seekToTimecode: @escaping (Message, Double, Bool) -> Void, scheduleCurrentMessage: @escaping () -> Void, sendScheduledMessagesNow: @escaping ([MessageId]) -> Void, editScheduledMessagesTime: @escaping ([MessageId]) -> Void, performTextSelectionAction: @escaping (UInt32, String, TextSelectionAction) -> Void, updateMessageReaction: @escaping (MessageId, String?) -> Void, openMessageReactions: @escaping (MessageId) -> Void, displaySwipeToReplyHint: @escaping () -> Void, dismissReplyMarkupMessage: @escaping (Message) -> Void, openMessagePollResults: @escaping (MessageId, Data) -> Void, openPollCreation: @escaping (Bool?) -> Void, displayPollSolution: @escaping (TelegramMediaPollResults.Solution, ASDisplayNode) -> Void, displayDiceTooltip: @escaping (TelegramMediaDice) -> Void, animateDiceSuccess: @escaping () -> Void, requestMessageUpdate: @escaping (MessageId) -> Void, cancelInteractiveKeyboardGestures: @escaping () -> Void, automaticMediaDownloadSettings: MediaAutoDownloadSettings, pollActionState: ChatInterfacePollActionState, stickerSettings: ChatInterfaceStickerSettings) { + init(openMessage: @escaping (Message, ChatControllerInteractionOpenMessageMode) -> Bool, openPeer: @escaping (PeerId?, ChatControllerInteractionNavigateToPeer, Message?) -> Void, openPeerMention: @escaping (String) -> Void, openMessageContextMenu: @escaping (Message, Bool, ASDisplayNode, CGRect, UIGestureRecognizer?) -> Void, openMessageContextActions: @escaping (Message, ASDisplayNode, CGRect, ContextGesture?) -> Void, navigateToMessage: @escaping (MessageId, MessageId) -> Void, tapMessage: ((Message) -> Void)?, clickThroughMessage: @escaping () -> Void, toggleMessagesSelection: @escaping ([MessageId], Bool) -> Void, sendCurrentMessage: @escaping (Bool) -> Void, sendMessage: @escaping (String) -> Void, sendSticker: @escaping (FileMediaReference, Bool, ASDisplayNode, CGRect) -> Bool, sendGif: @escaping (FileMediaReference, ASDisplayNode, CGRect) -> Bool, requestMessageActionCallback: @escaping (MessageId, MemoryBuffer?, Bool) -> Void, requestMessageActionUrlAuth: @escaping (String, MessageId, Int32) -> Void, activateSwitchInline: @escaping (PeerId?, String) -> Void, openUrl: @escaping (String, Bool, Bool?, Message?) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId?, String) -> Void, openInstantPage: @escaping (Message, ChatMessageItemAssociatedData?) -> Void, openWallpaper: @escaping (Message) -> Void, openTheme: @escaping (Message) -> Void, openHashtag: @escaping (String?, String) -> Void, updateInputState: @escaping ((ChatTextInputState) -> ChatTextInputState) -> Void, updateInputMode: @escaping ((ChatInputMode) -> ChatInputMode) -> Void, openMessageShareMenu: @escaping (MessageId) -> Void, presentController: @escaping (ViewController, Any?) -> Void, navigationController: @escaping () -> NavigationController?, chatControllerNode: @escaping () -> ASDisplayNode?, reactionContainerNode: @escaping () -> ReactionSelectionParentNode?, presentGlobalOverlayController: @escaping (ViewController, Any?) -> Void, callPeer: @escaping (PeerId) -> Void, longTap: @escaping (ChatControllerInteractionLongTapAction, Message?) -> Void, openCheckoutOrReceipt: @escaping (MessageId) -> Void, openSearch: @escaping () -> Void, setupReply: @escaping (MessageId) -> Void, canSetupReply: @escaping (Message) -> Bool, navigateToFirstDateMessage: @escaping(Int32) ->Void, requestRedeliveryOfFailedMessages: @escaping (MessageId) -> Void, addContact: @escaping (String) -> Void, rateCall: @escaping (Message, CallId) -> Void, requestSelectMessagePollOptions: @escaping (MessageId, [Data]) -> Void, requestOpenMessagePollResults: @escaping (MessageId, MediaId) -> Void, openAppStorePage: @escaping () -> Void, displayMessageTooltip: @escaping (MessageId, String, ASDisplayNode?, CGRect?) -> Void, seekToTimecode: @escaping (Message, Double, Bool) -> Void, scheduleCurrentMessage: @escaping () -> Void, sendScheduledMessagesNow: @escaping ([MessageId]) -> Void, editScheduledMessagesTime: @escaping ([MessageId]) -> Void, performTextSelectionAction: @escaping (UInt32, NSAttributedString, TextSelectionAction) -> Void, updateMessageReaction: @escaping (MessageId, String?) -> Void, openMessageReactions: @escaping (MessageId) -> Void, displaySwipeToReplyHint: @escaping () -> Void, dismissReplyMarkupMessage: @escaping (Message) -> Void, openMessagePollResults: @escaping (MessageId, Data) -> Void, openPollCreation: @escaping (Bool?) -> Void, displayPollSolution: @escaping (TelegramMediaPollResults.Solution, ASDisplayNode) -> Void, displayDiceTooltip: @escaping (TelegramMediaDice) -> Void, animateDiceSuccess: @escaping () -> Void, requestMessageUpdate: @escaping (MessageId) -> Void, cancelInteractiveKeyboardGestures: @escaping () -> Void, automaticMediaDownloadSettings: MediaAutoDownloadSettings, pollActionState: ChatInterfacePollActionState, stickerSettings: ChatInterfaceStickerSettings) { self.openMessage = openMessage self.openPeer = openPeer self.openPeerMention = openPeerMention diff --git a/submodules/TelegramUI/Sources/Pasteboard.swift b/submodules/TelegramUI/Sources/Pasteboard.swift index a2a76295aa..648a9aa4a1 100644 --- a/submodules/TelegramUI/Sources/Pasteboard.swift +++ b/submodules/TelegramUI/Sources/Pasteboard.swift @@ -26,28 +26,31 @@ private func rtfStringWithAppliedEntities(_ text: String, entities: [MessageText func chatInputStateStringFromRTF(_ data: Data, type: NSAttributedString.DocumentType) -> NSAttributedString? { if let attributedString = try? NSAttributedString(data: data, options: [NSAttributedString.DocumentReadingOptionKey.documentType: type], documentAttributes: nil) { - - let string = NSMutableAttributedString(string: attributedString.string) - attributedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length), options: [], using: { attributes, range, _ in - if let value = attributes[.link], let url = (value as? URL)?.absoluteString { - string.addAttribute(ChatTextInputAttributes.textUrl, value: ChatTextInputTextUrlAttribute(url: url), range: range) - } - else if let value = attributes[.font], let font = value as? UIFont { - let fontName = font.fontName.lowercased() - if fontName.contains("bold") { - string.addAttribute(ChatTextInputAttributes.bold, value: true as NSNumber, range: range) - } else if fontName.contains("italic") { - string.addAttribute(ChatTextInputAttributes.italic, value: true as NSNumber, range: range) - } else if fontName.contains("menlo") || fontName.contains("courier") || fontName.contains("sfmono") { - string.addAttribute(ChatTextInputAttributes.monospace, value: true as NSNumber, range: range) - } - } - }) - return string + return chatInputStateString(attributedString: attributedString) } return nil } +private func chatInputStateString(attributedString: NSAttributedString) -> NSAttributedString? { + let string = NSMutableAttributedString(string: attributedString.string) + attributedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length), options: [], using: { attributes, range, _ in + if let value = attributes[.link], let url = (value as? URL)?.absoluteString { + string.addAttribute(ChatTextInputAttributes.textUrl, value: ChatTextInputTextUrlAttribute(url: url), range: range) + } + else if let value = attributes[.font], let font = value as? UIFont { + let fontName = font.fontName.lowercased() + if fontName.contains("bold") { + string.addAttribute(ChatTextInputAttributes.bold, value: true as NSNumber, range: range) + } else if fontName.contains("italic") { + string.addAttribute(ChatTextInputAttributes.italic, value: true as NSNumber, range: range) + } else if fontName.contains("menlo") || fontName.contains("courier") || fontName.contains("sfmono") { + string.addAttribute(ChatTextInputAttributes.monospace, value: true as NSNumber, range: range) + } + } + }) + return string +} + func storeMessageTextInPasteboard(_ text: String, entities: [MessageTextEntity]?) { var items: [String: Any] = [:] items[kUTTypeUTF8PlainText as String] = text @@ -58,6 +61,13 @@ func storeMessageTextInPasteboard(_ text: String, entities: [MessageTextEntity]? UIPasteboard.general.items = [items] } +func storeAttributedTextInPasteboard(_ text: NSAttributedString) { + if let inputText = chatInputStateString(attributedString: text) { + let entities = generateChatInputTextEntities(inputText) + storeMessageTextInPasteboard(inputText.string, entities: entities) + } +} + func storeInputTextInPasteboard(_ text: NSAttributedString) { let entities = generateChatInputTextEntities(text) storeMessageTextInPasteboard(text.string, entities: entities) diff --git a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift index 81fc232615..bf3b127fcf 100644 --- a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift +++ b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift @@ -196,7 +196,7 @@ public final class TextSelectionNode: ASDisplayNode { private let updateIsActive: (Bool) -> Void private let present: (ViewController, Any?) -> Void private weak var rootNode: ASDisplayNode? - private let performAction: (String, TextSelectionAction) -> Void + private let performAction: (NSAttributedString, TextSelectionAction) -> Void private var highlightOverlay: LinkHighlightingNode? private let leftKnob: ASImageNode private let rightKnob: ASImageNode @@ -209,7 +209,7 @@ public final class TextSelectionNode: ASDisplayNode { private var recognizer: TextSelectionGetureRecognizer? private var displayLinkAnimator: DisplayLinkAnimator? - public init(theme: TextSelectionTheme, strings: PresentationStrings, textNode: TextNode, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: ASDisplayNode, performAction: @escaping (String, TextSelectionAction) -> Void) { + 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 self.textNode = textNode @@ -377,7 +377,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func pretendExtendSelection(to index: Int) { - guard let cachedLayout = self.textNode.cachedLayout, let attributedString = cachedLayout.attributedString, let endRangeRect = cachedLayout.rangeRects(in: NSRange(location: index, length: 1))?.rects.first else { + guard let cachedLayout = self.textNode.cachedLayout, let _ = cachedLayout.attributedString, let endRangeRect = cachedLayout.rangeRects(in: NSRange(location: index, length: 1))?.rects.first else { return } let startPoint = self.rightKnob.frame.center @@ -492,19 +492,19 @@ public final class TextSelectionNode: ASDisplayNode { } completeRect = completeRect.insetBy(dx: 0.0, dy: -12.0) - let text = (attributedString.string as NSString).substring(with: range) + let attributedText = attributedString.attributedSubstring(from: range) var actions: [ContextMenuAction] = [] actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: { [weak self] in - self?.performAction(text, .copy) + self?.performAction(attributedText, .copy) self?.dismissSelection() })) actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuLookUp, accessibilityLabel: self.strings.Conversation_ContextMenuLookUp), action: { [weak self] in - self?.performAction(text, .lookup) + self?.performAction(attributedText, .lookup) self?.dismissSelection() })) actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuShare, accessibilityLabel: self.strings.Conversation_ContextMenuShare), action: { [weak self] in - self?.performAction(text, .share) + self?.performAction(attributedText, .share) self?.dismissSelection() })) self.present(ContextMenuController(actions: actions, catchTapsOutside: false, hasHapticFeedback: false), ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in