// // ContentTextView.swift // Tusker // // Created by Shadowfacts on 1/18/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import SwiftSoup import Pachyderm import SafariServices private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) class ContentTextView: LinkTextView { weak var navigationDelegate: TuskerNavigationDelegate? weak var overrideMastodonController: MastodonController? var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController } var defaultFont: UIFont = .systemFont(ofSize: 17) var defaultColor: UIColor = .label override func awakeFromNib() { super.awakeFromNib() delegate = self // Disable layer masking, otherwise the context menu opening animation // may be clipped if it's at an edge of the text view layer.masksToBounds = false addInteraction(UIContextMenuInteraction(delegate: self)) textDragInteraction?.isEnabled = false textContainerInset = .zero textContainer.lineFragmentPadding = 0 // the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:))) addGestureRecognizer(recognizer) } // MARK: - Emojis func setEmojis(_ emojis: [Emoji]) { guard !emojis.isEmpty else { return } let emojiImages = CachedDictionary(name: "ContentTextView Emoji Images") let group = DispatchGroup() for emoji in emojis { group.enter() _ = ImageCache.emojis.get(emoji.url) { (data) in defer { group.leave() } guard let data = data, let image = UIImage(data: data) else { return } emojiImages[emoji.shortcode] = image } } group.notify(queue: .main) { let mutAttrString = NSMutableAttributedString(attributedString: self.attributedText!) let string = mutAttrString.string let matches = emojiRegex.matches(in: string, options: [], range: mutAttrString.fullRange) // replaces the emojis started from the end of the string as to not alter the indexes of the other emojis for match in matches.reversed() { let shortcode = (string as NSString).substring(with: match.range(at: 1)) guard let emojiImage = emojiImages[shortcode] else { continue } let attachment = NSTextAttachment(emojiImage: emojiImage, in: self.font!, with: self.textColor ?? .label) let attachmentStr = NSAttributedString(attachment: attachment) mutAttrString.replaceCharacters(in: match.range, with: attachmentStr) } self.attributedText = mutAttrString self.setNeedsLayout() self.setNeedsDisplay() } } // MARK: - HTML Parsing func setTextFromHtml(_ html: String) { let doc = try! SwiftSoup.parse(html) let body = doc.body()! let attributedText = attributedTextForHTMLNode(body) let mutAttrString = NSMutableAttributedString(attributedString: attributedText) mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) mutAttrString.collapseWhitespace() self.attributedText = mutAttrString } private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString { switch node { case let node as TextNode: let text: String if usePreformattedText { text = node.getWholeText() } else { text = node.text() } return NSAttributedString(string: text, attributes: [.font: defaultFont, .foregroundColor: defaultColor]) case let node as Element: let attributed = NSMutableAttributedString(string: "", attributes: [.font: defaultFont, .foregroundColor: defaultColor]) for child in node.getChildNodes() { attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre")) } switch node.tagName() { case "br": attributed.append(NSAttributedString(string: "\n")) case "a": if let link = try? node.attr("href"), let url = URL(string: link) { attributed.addAttribute(.link, value: url, range: attributed.fullRange) } case "p": attributed.append(NSAttributedString(string: "\n\n")) case "em", "i": let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font! attributed.addAttribute(.font, value: currentFont.addingTraits(.traitItalic)!, range: attributed.fullRange) case "strong", "b": let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font! attributed.addAttribute(.font, value: currentFont.addingTraits(.traitBold)!, range: attributed.fullRange) case "del": attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange) case "code": attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: self.font!.pointSize, weight: .regular), range: attributed.fullRange) case "pre": attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: self.font!.pointSize, weight: .regular), range: attributed.fullRange) attributed.append(NSAttributedString(string: "\n\n")) case "ol", "ul": attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines) attributed.append(NSAttributedString(string: "\n\n")) case "li": let parentEl = node.parent()! let parentTag = parentEl.tagName() let bullet: NSAttributedString if parentTag == "ol" { let index = (try? node.elementSiblingIndex()) ?? 0 // we use the monospace digit font so that the periods of all the list items line up bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: self.font!.pointSize, weight: .regular)]) } else if parentTag == "ul" { bullet = NSAttributedString(string: "\u{2022}\t") } else { bullet = NSAttributedString() } attributed.insert(bullet, at: 0) attributed.append(NSAttributedString(string: "\n")) default: break } return attributed default: fatalError("Unexpected node type \(type(of: node))") } } // MARK: - Interaction // only accept touches that are over a link override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if getLinkAtPoint(point) != nil || isSelectable { return super.hitTest(point, with: event) } else { return nil } } @objc func textTapped(_ recognizer: UITapGestureRecognizer) { // if there currently is a selection, deselct it on single-tap if selectedRange.length > 0 { // location doesn't matter since we are non-editable and the cursor isn't visible selectedRange = NSRange(location: 0, length: 0) } let location = recognizer.location(in: self) if let (link, range) = getLinkAtPoint(location) { let text = (self.text as NSString).substring(with: range) handleLinkTapped(url: link, text: text) } } func getLinkAtPoint(_ point: CGPoint) -> (URL, NSRange)? { let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top) var partialFraction: CGFloat = 0 let characterIndex = layoutManager.characterIndex(for: locationInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: &partialFraction) if characterIndex < textStorage.length { var range = NSRange() if let link = textStorage.attribute(.link, at: characterIndex, longestEffectiveRange: &range, in: textStorage.fullRange) as? URL { return (link, range) } } return nil } func handleLinkTapped(url: URL, text: String) { if let mention = getMention(for: url, text: text) { navigationDelegate?.selected(mention: mention) } else if let tag = getHashtag(for: url, text: text) { navigationDelegate?.selected(tag: tag) } else { navigationDelegate?.selected(url: url) } } // MARK: - Navigation func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController { let text = (self.text as NSString).substring(with: range) if let mention = getMention(for: url, text: text) { return ProfileTableViewController(accountID: mention.id, mastodonController: mastodonController!) } else if let tag = getHashtag(for: url, text: text) { return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!) } else { return SFSafariViewController(url: url) } } open func getMention(for url: URL, text: String) -> Mention? { return nil } open func getHashtag(for url: URL, text: String) -> Hashtag? { if text.starts(with: "#") { let tag = String(text.dropFirst()) return Hashtag(name: tag, url: url) } else { return nil } } } extension ContentTextView: UITextViewDelegate { func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { // disable the text view's link interactions, we handle tapping links ourself with a gesture recognizer return false } } extension ContentTextView: MenuPreviewProvider { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { fatalError("unimplemented") } } extension ContentTextView: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { if let (link, range) = getLinkAtPoint(location) { // Determine the line rects that the link takes up in the coordinate space of this view var rects = [CGRect]() layoutManager.enumerateEnclosingRects(forGlyphRange: range, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { (rect, stop) in rects.append(rect) } let preview: UIContextMenuContentPreviewProvider = { self.getViewController(forLink: link, inRange: range) } let actions: UIContextMenuActionProvider = { (_) in let text = (self.text as NSString).substring(with: range) let actions: [UIMenuElement] if let mention = self.getMention(for: link, text: text) { actions = self.actionsForProfile(accountID: mention.id, sourceView: self) } else if let tag = self.getHashtag(for: link, text: text) { actions = self.actionsForHashtag(tag, sourceView: self) } else { actions = self.actionsForURL(link, sourceView: self) } return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions) } // Use a custom UIContentMenuConfiguration subclass to pass the text line rect information // to the `contextMenuInteraction(_:previewForHighlightingMenuWithConfiguration:)` method. let configuration = ContextMenuConfiguration(identifier: nil, previewProvider: preview, actionProvider: actions) configuration.textLineRects = rects return configuration } else { return nil } } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { // If there isn't custom text line rect data, use the default system-generated preview. guard let config = configuration as? ContextMenuConfiguration, let rects = config.textLineRects, rects.count > 0 else { return nil } // Calculate the smallest rect enclosing all of the text line rects, in the coordinate space of this view. var minX: CGFloat = .greatestFiniteMagnitude, maxX: CGFloat = .leastNonzeroMagnitude, minY: CGFloat = .greatestFiniteMagnitude, maxY: CGFloat = .leastNonzeroMagnitude for rect in rects { minX = min(rect.minX, minX) maxX = max(rect.maxX, maxX) minY = min(rect.minY, minY) maxY = max(rect.maxY, maxY) } let rectEnclosingTextLineRects = CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) // Try to create a snapshot view of this view that only shows the minimum // rectangle necessary to fully display the link text (reduces the likelihood that // other text will be displayed alongside it). // If a snapshot view cannot be created, we bail and use the system-provided preview. guard let snapshot = self.resizableSnapshotView(from: rectEnclosingTextLineRects, afterScreenUpdates: false, withCapInsets: .zero) else { return nil } // Convert the textLineRects from the context menu configuration to be in the // coordinate space of the snapshot view. The snapshot view is created from // rectEnclosingTextLineRects, which means that, while its size is the same as the // enclosing rect, its coordinate space is relative to this text views by rectEnclosingTextLineRects.origin. // Since the text line rects passed to UIPreviewParameters need to be in the coordinate space of // the preview view, we subtract the origin position from each rect to convert to the snapshot view's // coordinate space. let rectsInCoordinateSpaceOfEnclosingRect = rects.map { $0.offsetBy(dx: -rectEnclosingTextLineRects.minX, dy: -rectEnclosingTextLineRects.minY) } // The preview parameters describe how the preview view is shown inside the prev. let parameters = UIPreviewParameters(textLineRects: rectsInCoordinateSpaceOfEnclosingRect as [NSValue]) // todo: parameters.visiblePath around text // The center point of the the minimum enclosing rect in our coordinate space is the point where the // center of the preview should be, since that's also in this view's coordinate space. let rectsCenter = CGPoint(x: rectEnclosingTextLineRects.midX, y: rectEnclosingTextLineRects.midY) // The preview target describes how the preview is positioned. let target = UIPreviewTarget(container: self, center: rectsCenter) return UITargetedPreview(view: snapshot, parameters: parameters, target: target) } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { if let viewController = animator.previewViewController { animator.preferredCommitStyle = .pop animator.addCompletion { self.navigationDelegate?.show(viewController) } } } /// Used to pass text line rect data between `contextMenuInteraction(_:configurationForMenuAtLocation:)` and `contextMenuInteraction(_:previewForHighlightingMenuWithConfiguration:)` fileprivate class ContextMenuConfiguration: UIContextMenuConfiguration { /// The line rects of the source of this context menu configuration in the coordinate space of the preview target view. var textLineRects: [CGRect]? } }