Preserve text formatting when copying a part of message text

This commit is contained in:
Ilya Laktyushin 2020-04-24 17:09:20 +04:00
parent b99f4b6988
commit 5a6963b125
4 changed files with 40 additions and 30 deletions

View File

@ -1874,13 +1874,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
} }
switch action { switch action {
case .copy: case .copy:
UIPasteboard.general.string = text storeAttributedTextInPasteboard(text)
case .share: case .share:
let f = { let f = {
guard let strongSelf = self else { guard let strongSelf = self else {
return 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.chatDisplayNode.dismissInput()
strongSelf.present(shareController, in: .window(.root)) strongSelf.present(shareController, in: .window(.root))
} }
@ -1892,7 +1892,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
f() f()
} }
case .lookup: case .lookup:
let controller = UIReferenceLibraryViewController(term: text) let controller = UIReferenceLibraryViewController(term: text.string)
if let window = strongSelf.effectiveNavigationController?.view.window { if let window = strongSelf.effectiveNavigationController?.view.window {
controller.popoverPresentationController?.sourceView = 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)) 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))

View File

@ -100,7 +100,7 @@ public final class ChatControllerInteraction {
let scheduleCurrentMessage: () -> Void let scheduleCurrentMessage: () -> Void
let sendScheduledMessagesNow: ([MessageId]) -> Void let sendScheduledMessagesNow: ([MessageId]) -> Void
let editScheduledMessagesTime: ([MessageId]) -> Void let editScheduledMessagesTime: ([MessageId]) -> Void
let performTextSelectionAction: (UInt32, String, TextSelectionAction) -> Void let performTextSelectionAction: (UInt32, NSAttributedString, TextSelectionAction) -> Void
let updateMessageReaction: (MessageId, String?) -> Void let updateMessageReaction: (MessageId, String?) -> Void
let openMessageReactions: (MessageId) -> Void let openMessageReactions: (MessageId) -> Void
let displaySwipeToReplyHint: () -> Void let displaySwipeToReplyHint: () -> Void
@ -126,7 +126,7 @@ public final class ChatControllerInteraction {
var searchTextHighightState: (String, [MessageIndex])? var searchTextHighightState: (String, [MessageIndex])?
var seenOneTimeAnimatedMedia = Set<MessageId>() var seenOneTimeAnimatedMedia = Set<MessageId>()
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.openMessage = openMessage
self.openPeer = openPeer self.openPeer = openPeer
self.openPeerMention = openPeerMention self.openPeerMention = openPeerMention

View File

@ -26,28 +26,31 @@ private func rtfStringWithAppliedEntities(_ text: String, entities: [MessageText
func chatInputStateStringFromRTF(_ data: Data, type: NSAttributedString.DocumentType) -> NSAttributedString? { func chatInputStateStringFromRTF(_ data: Data, type: NSAttributedString.DocumentType) -> NSAttributedString? {
if let attributedString = try? NSAttributedString(data: data, options: [NSAttributedString.DocumentReadingOptionKey.documentType: type], documentAttributes: nil) { if let attributedString = try? NSAttributedString(data: data, options: [NSAttributedString.DocumentReadingOptionKey.documentType: type], documentAttributes: nil) {
return chatInputStateString(attributedString: attributedString)
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 nil 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]?) { func storeMessageTextInPasteboard(_ text: String, entities: [MessageTextEntity]?) {
var items: [String: Any] = [:] var items: [String: Any] = [:]
items[kUTTypeUTF8PlainText as String] = text items[kUTTypeUTF8PlainText as String] = text
@ -58,6 +61,13 @@ func storeMessageTextInPasteboard(_ text: String, entities: [MessageTextEntity]?
UIPasteboard.general.items = [items] 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) { func storeInputTextInPasteboard(_ text: NSAttributedString) {
let entities = generateChatInputTextEntities(text) let entities = generateChatInputTextEntities(text)
storeMessageTextInPasteboard(text.string, entities: entities) storeMessageTextInPasteboard(text.string, entities: entities)

View File

@ -196,7 +196,7 @@ public final class TextSelectionNode: ASDisplayNode {
private let updateIsActive: (Bool) -> Void private let updateIsActive: (Bool) -> Void
private let present: (ViewController, Any?) -> Void private let present: (ViewController, Any?) -> Void
private weak var rootNode: ASDisplayNode? private weak var rootNode: ASDisplayNode?
private let performAction: (String, TextSelectionAction) -> Void private let performAction: (NSAttributedString, TextSelectionAction) -> Void
private var highlightOverlay: LinkHighlightingNode? private var highlightOverlay: LinkHighlightingNode?
private let leftKnob: ASImageNode private let leftKnob: ASImageNode
private let rightKnob: ASImageNode private let rightKnob: ASImageNode
@ -209,7 +209,7 @@ public final class TextSelectionNode: ASDisplayNode {
private var recognizer: TextSelectionGetureRecognizer? private var recognizer: TextSelectionGetureRecognizer?
private var displayLinkAnimator: DisplayLinkAnimator? 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.theme = theme
self.strings = strings self.strings = strings
self.textNode = textNode self.textNode = textNode
@ -377,7 +377,7 @@ public final class TextSelectionNode: ASDisplayNode {
} }
public func pretendExtendSelection(to index: Int) { 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 return
} }
let startPoint = self.rightKnob.frame.center let startPoint = self.rightKnob.frame.center
@ -492,19 +492,19 @@ public final class TextSelectionNode: ASDisplayNode {
} }
completeRect = completeRect.insetBy(dx: 0.0, dy: -12.0) 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] = [] var actions: [ContextMenuAction] = []
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: { [weak self] in 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() self?.dismissSelection()
})) }))
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuLookUp, accessibilityLabel: self.strings.Conversation_ContextMenuLookUp), action: { [weak self] in 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() self?.dismissSelection()
})) }))
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuShare, accessibilityLabel: self.strings.Conversation_ContextMenuShare), action: { [weak self] in 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?.dismissSelection()
})) }))
self.present(ContextMenuController(actions: actions, catchTapsOutside: false, hasHapticFeedback: false), ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in self.present(ContextMenuController(actions: actions, catchTapsOutside: false, hasHapticFeedback: false), ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in