forked from shadowfacts/Tusker
349 lines
16 KiB
Swift
349 lines
16 KiB
Swift
//
|
|
// ContentTextView.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 1/18/20.
|
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import SwiftSoup
|
|
import Pachyderm
|
|
import SafariServices
|
|
import WebURL
|
|
import WebURLFoundationExtras
|
|
|
|
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 var htmlConverter = HTMLConverter()
|
|
var defaultFont: UIFont {
|
|
_read { yield htmlConverter.font }
|
|
_modify { yield &htmlConverter.font }
|
|
}
|
|
var monospaceFont: UIFont {
|
|
_read { yield htmlConverter.monospaceFont }
|
|
_modify { yield &htmlConverter.monospaceFont }
|
|
}
|
|
var defaultColor: UIColor {
|
|
_read { yield htmlConverter.color }
|
|
_modify { yield &htmlConverter.color }
|
|
}
|
|
var paragraphStyle: NSParagraphStyle {
|
|
_read { yield htmlConverter.paragraphStyle }
|
|
_modify { yield &htmlConverter.paragraphStyle }
|
|
}
|
|
|
|
private(set) var hasEmojis = false
|
|
|
|
var emojiIdentifier: AnyHashable?
|
|
var emojiRequests: [ImageCache.Request] = []
|
|
var emojiFont: UIFont { defaultFont }
|
|
var emojiTextColor: UIColor { defaultColor }
|
|
|
|
// 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?
|
|
|
|
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
|
|
|
|
// 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<ID: Hashable>(_ 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 setTextFromHtml(_ html: String) {
|
|
self.attributedText = htmlConverter.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) {
|
|
return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!)
|
|
} else if let tag = getHashtag(for: url, text: text) {
|
|
return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!)
|
|
} else if url.scheme == "https" || url.scheme == "http" {
|
|
let vc = SFSafariViewController(url: url)
|
|
vc.preferredControlTintColor = Preferences.shared.accentColor.color
|
|
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 {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ContentTextView: MenuActionProvider {
|
|
var toastableViewController: ToastableViewController? {
|
|
// todo: pass this down through the text view
|
|
nil
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|