// // ContentTextView.swift // Tusker // // Created by Shadowfacts on 1/18/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import SafariServices import WebURL import WebURLFoundationExtras import Combine private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let dataDetectorsScheme = "x-apple-data-detectors" class ContentTextView: LinkTextView, BaseEmojiLabel { weak var navigationDelegate: TuskerNavigationDelegate? weak var overrideMastodonController: MastodonController? var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController } private static let defaultBodyHTMLConverter = HTMLConverter( font: .preferredFont(forTextStyle: .body), monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)), color: .label, paragraphStyle: .default ) private(set) var hasEmojis = false var emojiIdentifier: AnyHashable? var emojiRequests: [ImageCache.Request] = [] var emojiFont: UIFont = .preferredFont(forTextStyle: .body) var emojiTextColor: UIColor = .label // The link range currently being previewed private var currentPreviewedLinkRange: NSRange? // The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing. private weak var currentTargetedPreview: UITargetedPreview? private var underlineTextLinksCancellable: AnyCancellable? override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) commonInit() } required init?(coder: NSCoder) { super.init(coder: coder) commonInit() } private func commonInit() { 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 #if os(visionOS) linkTextAttributes = [ .foregroundColor: UIColor.link ] #else linkTextAttributes = [ .foregroundColor: UIColor.tintColor ] #endif updateLinkUnderlineStyle() // 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) NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil) underlineTextLinksCancellable = Preferences.shared.$underlineTextLinks .sink { [unowned self] in self.updateLinkUnderlineStyle(preference: $0) } } @objc private func _updateLinkUnderlineStyle() { updateLinkUnderlineStyle() } @MainActor private func updateLinkUnderlineStyle(preference: Bool? = nil) { let preference = preference ?? Preferences.shared.underlineTextLinks if UIAccessibility.buttonShapesEnabled || preference { linkTextAttributes[.underlineStyle] = NSUnderlineStyle.single.rawValue } else { linkTextAttributes.removeValue(forKey: .underlineStyle) } } // MARK: - Emojis func setEmojis(_ emojis: [Emoji], identifier: ID?) { replaceEmojis(in: attributedText!, emojis: emojis, identifier: identifier) { attributedString, didReplaceEmojis in guard didReplaceEmojis else { return } self.attributedText = attributedString self.setNeedsLayout() self.setNeedsDisplay() } } // MARK: - HTML Parsing func setBodyTextFromHTML(_ html: String) { self.attributedText = ContentTextView.defaultBodyHTMLConverter.convert(html) } // 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), link.scheme != dataDetectorsScheme { 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) if #available(iOS 16.0, *), let textLayoutManager { guard let fragment = textLayoutManager.textLayoutFragment(for: locationInTextContainer) else { return nil } let pointInLayoutFragment = CGPoint(x: locationInTextContainer.x - fragment.layoutFragmentFrame.minX, y: locationInTextContainer.y - fragment.layoutFragmentFrame.minY) guard let lineFragment = fragment.textLineFragments.first(where: { lineFragment in lineFragment.typographicBounds.contains(pointInLayoutFragment) }) else { return nil } let pointInLineFragment = CGPoint(x: pointInLayoutFragment.x - lineFragment.typographicBounds.minX, y: pointInLayoutFragment.y - lineFragment.typographicBounds.minY) let charIndex = lineFragment.characterIndex(for: pointInLineFragment) var range = NSRange() // sometimes characterIndex(for:) returns NSNotFound even for points that are in the line fragment's typographic bounds (see #183), so we check just in case guard charIndex != NSNotFound, let link = lineFragment.attributedString.attribute(.link, at: charIndex, longestEffectiveRange: &range, in: lineFragment.attributedString.fullRange) as? URL else { return nil } // lineFragment.attributedString is the NSTextLayoutFragment's string, and so range is in its index space // but we need to return a range in our whole attributedString's space, so convert it let textLayoutFragmentStart = textLayoutManager.offset(from: textLayoutManager.documentRange.location, to: fragment.rangeInElement.location) let rangeInSelf = NSRange(location: range.location + textLayoutFragmentStart, length: range.length) return (link, rangeInSelf) } else { var partialFraction: CGFloat = 0 let characterIndex = layoutManager.characterIndex(for: locationInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: &partialFraction) guard characterIndex < textStorage.length && partialFraction < 1 else { return nil } var range = NSRange() guard let link = textStorage.attribute(.link, at: characterIndex, longestEffectiveRange: &range, in: textStorage.fullRange) as? URL else { return nil } return (link, range) } } 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), let mastodonController { return ProfileViewController(accountID: mention.id, mastodonController: mastodonController) } else if let tag = getHashtag(for: url, text: text), let mastodonController { return HashtagTimelineViewController(for: tag, mastodonController: mastodonController) } else if url.scheme == "https" || url.scheme == "http" { let vc = SFSafariViewController(url: url) #if !os(visionOS) vc.preferredControlTintColor = Preferences.shared.accentColor.color #endif return vc } else { return nil } } 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 { #if os(visionOS) func textView(_ textView: UITextView, primaryActionFor textItem: UITextItem, defaultAction: UIAction) -> UIAction? { guard case .link(let url) = textItem.content else { return defaultAction } if url.scheme == dataDetectorsScheme { return defaultAction } else { return UIAction { _ in self.handleLinkTapped(url: url, text: (self.text as NSString).substring(with: textItem.range)) } } } #else func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { // the builtin data detectors use the x-apple-data-detectors scheme, and we allow the text view to handle those itself if URL.scheme == dataDetectorsScheme { return true } else { // otherwise, regular taps are handled by the gesture recognizer, but the accessibility interaction to select links with the rotor goes through here // and this seems to be the only way of overriding what it does if interaction == .invokeDefaultAction { handleLinkTapped(url: URL, text: (text as NSString).substring(with: characterRange)) } return false } } #endif } extension ContentTextView: MenuActionProvider { } extension ContentTextView: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { if let (link, range) = getLinkAtPoint(location) { // Store the previewed link range for use in the previewForHighlighting method currentPreviewedLinkRange = range 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, source: .view(self)) } else if let tag = self.getHashtag(for: link, text: text) { actions = self.actionsForHashtag(tag, source: .view(self)) } else { actions = self.actionsForURL(link, source: .view(self)) } return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions) } return UIContextMenuConfiguration(identifier: nil, previewProvider: preview, actionProvider: actions) } else { currentPreviewedLinkRange = nil return nil } } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { // If there isn't a link range, use the default system-generated preview. guard let range = currentPreviewedLinkRange else { return nil } currentPreviewedLinkRange = nil // Determine the line rects that the link takes up in the coordinate space of this view. var rects = [CGRect]() if #available(iOS 16.0, *), let textLayoutManager, let contentManager = textLayoutManager.textContentManager { // convert from NSRange to NSTextRange // i have no idea under what circumstances any of these calls could fail guard let startLoc = contentManager.location(contentManager.documentRange.location, offsetBy: range.location), let endLoc = contentManager.location(startLoc, offsetBy: range.length), let textRange = NSTextRange(location: startLoc, end: endLoc) else { return nil } // .standard because i have no idea what the difference is textLayoutManager.enumerateTextSegments(in: textRange, type: .standard, options: .rangeNotRequired) { range, rect, float, textContainer in rects.append(rect) return true } } else { layoutManager.enumerateEnclosingRects(forGlyphRange: range, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { (rect, stop) in rects.append(rect) } } // Try to create a snapshot view of this view to disply as the preview. // If a snapshot view cannot be created, we bail and use the system-provided preview. guard let snapshot = self.snapshotView(afterScreenUpdates: false) else { return nil } // Mask the snapshot layer to only show the text of the link, and nothing else. // By default, the system-applied mask is too wide and other content may seep in. let path = UIBezierPath(wrappingAround: rects) let maskLayer = CAShapeLayer() maskLayer.path = path.cgPath snapshot.layer.mask = maskLayer // The preview parameters describe how the preview view is shown inside the preview. let parameters = UIPreviewParameters(textLineRects: rects as [NSValue]) // Calculate the smallest rect enclosing all of the text line rects, in the coordinate space of this view. var minX: CGFloat = .greatestFiniteMagnitude, maxX: CGFloat = -.greatestFiniteMagnitude, minY: CGFloat = .greatestFiniteMagnitude, maxY: CGFloat = -.greatestFiniteMagnitude for rect in rects { minX = min(rect.minX, minX) maxX = max(rect.maxX, maxX) minY = min(rect.minY, minY) maxY = max(rect.maxY, maxY) } // 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: (minX + maxX) / 2, y: (minY + maxY) / 2) // The preview target describes how the preview is positioned. let target = UIPreviewTarget(container: self, center: rectsCenter) // Create a dummy containerview for the snapshot view, since using a view with a CALayer mask and UIPreviewParameters(textLineRects:) // causes the mask to be ignored. See FB7832297 let snapshotContainer = UIView(frame: snapshot.bounds) snapshotContainer.backgroundColor = .appBackground snapshotContainer.addSubview(snapshot) let preview = UITargetedPreview(view: snapshotContainer, parameters: parameters, target: target) currentTargetedPreview = preview return preview } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { // Use the same preview for dismissing as was used for highlighting, so that the link animates back to the original position. return currentTargetedPreview } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { if let viewController = animator.previewViewController { animator.preferredCommitStyle = .pop animator.addCompletion { self.navigationDelegate?.show(viewController) } } } }