2020-01-18 21:00:38 +00:00
|
|
|
//
|
|
|
|
// 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 {
|
|
|
|
|
2020-01-20 04:02:07 +00:00
|
|
|
weak var navigationDelegate: TuskerNavigationDelegate?
|
2020-01-20 20:25:23 +00:00
|
|
|
weak var overrideMastodonController: MastodonController?
|
|
|
|
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
|
|
|
|
|
2020-01-18 21:00:38 +00:00
|
|
|
var defaultFont: UIFont = .systemFont(ofSize: 17)
|
2020-01-18 23:21:01 +00:00
|
|
|
var defaultColor: UIColor = .label
|
2020-01-18 21:00:38 +00:00
|
|
|
|
|
|
|
override func awakeFromNib() {
|
|
|
|
super.awakeFromNib()
|
|
|
|
|
|
|
|
delegate = self
|
|
|
|
|
2020-06-25 14:42:46 +00:00
|
|
|
// 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
|
2020-01-18 21:00:38 +00:00
|
|
|
addInteraction(UIContextMenuInteraction(delegate: self))
|
|
|
|
|
|
|
|
textDragInteraction?.isEnabled = false
|
2020-01-18 21:27:18 +00:00
|
|
|
|
|
|
|
textContainerInset = .zero
|
|
|
|
textContainer.lineFragmentPadding = 0
|
2020-01-19 00:32:39 +00:00
|
|
|
|
|
|
|
// 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)
|
2020-01-18 21:00:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Emojis
|
|
|
|
func setEmojis(_ emojis: [Emoji]) {
|
|
|
|
guard !emojis.isEmpty else { return }
|
|
|
|
|
|
|
|
let emojiImages = CachedDictionary<UIImage>(name: "ContentTextView Emoji Images")
|
|
|
|
|
|
|
|
let group = DispatchGroup()
|
|
|
|
|
|
|
|
for emoji in emojis {
|
|
|
|
group.enter()
|
2020-01-25 15:06:27 +00:00
|
|
|
_ = ImageCache.emojis.get(emoji.url) { (data) in
|
2020-01-18 21:00:38 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2020-03-02 00:40:32 +00:00
|
|
|
let attachment = NSTextAttachment(emojiImage: emojiImage, in: self.font!, with: self.textColor ?? .label)
|
2020-01-18 21:00:38 +00:00
|
|
|
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)
|
2020-01-22 02:21:23 +00:00
|
|
|
mutAttrString.collapseWhitespace()
|
2020-01-18 21:00:38 +00:00
|
|
|
|
|
|
|
self.attributedText = mutAttrString
|
|
|
|
}
|
|
|
|
|
2020-01-22 02:21:23 +00:00
|
|
|
private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString {
|
2020-01-18 21:00:38 +00:00
|
|
|
switch node {
|
|
|
|
case let node as TextNode:
|
2020-01-18 21:05:44 +00:00
|
|
|
let text: String
|
|
|
|
if usePreformattedText {
|
|
|
|
text = node.getWholeText()
|
|
|
|
} else {
|
|
|
|
text = node.text()
|
|
|
|
}
|
2020-01-18 23:21:01 +00:00
|
|
|
return NSAttributedString(string: text, attributes: [.font: defaultFont, .foregroundColor: defaultColor])
|
2020-01-18 21:00:38 +00:00
|
|
|
case let node as Element:
|
2020-01-18 23:21:01 +00:00
|
|
|
let attributed = NSMutableAttributedString(string: "", attributes: [.font: defaultFont, .foregroundColor: defaultColor])
|
2020-01-18 21:00:38 +00:00
|
|
|
for child in node.getChildNodes() {
|
2020-01-18 21:05:44 +00:00
|
|
|
attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre"))
|
2020-01-18 21:00:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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? {
|
2020-01-18 21:18:32 +00:00
|
|
|
if getLinkAtPoint(point) != nil || isSelectable {
|
2020-01-18 23:38:00 +00:00
|
|
|
return super.hitTest(point, with: event)
|
2020-01-18 21:00:38 +00:00
|
|
|
} else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-19 00:32:39 +00:00
|
|
|
@objc func textTapped(_ recognizer: UITapGestureRecognizer) {
|
2020-05-08 01:46:59 +00:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2020-01-19 00:32:39 +00:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-18 21:00:38 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2020-01-19 00:32:39 +00:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-18 21:00:38 +00:00
|
|
|
// 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) {
|
2020-01-18 23:56:36 +00:00
|
|
|
return ProfileTableViewController(accountID: mention.id, mastodonController: mastodonController!)
|
2020-01-18 21:00:38 +00:00
|
|
|
} else if let tag = getHashtag(for: url, text: text) {
|
2020-01-18 23:56:36 +00:00
|
|
|
return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!)
|
2020-01-18 21:00:38 +00:00
|
|
|
} 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 {
|
2020-01-19 00:32:39 +00:00
|
|
|
// disable the text view's link interactions, we handle tapping links ourself with a gesture recognizer
|
2020-01-18 21:00:38 +00:00
|
|
|
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) {
|
2020-06-25 14:42:46 +00:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2020-01-18 21:00:38 +00:00
|
|
|
let preview: UIContextMenuContentPreviewProvider = {
|
|
|
|
self.getViewController(forLink: link, inRange: range)
|
|
|
|
}
|
|
|
|
let actions: UIContextMenuActionProvider = { (_) in
|
|
|
|
let text = (self.text as NSString).substring(with: range)
|
2020-06-27 04:22:14 +00:00
|
|
|
let actions: [UIMenuElement]
|
2020-01-18 21:00:38 +00:00
|
|
|
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)
|
|
|
|
}
|
2020-06-25 14:42:46 +00:00
|
|
|
|
|
|
|
// 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
|
2020-01-18 21:00:38 +00:00
|
|
|
} else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
2020-06-25 14:42:46 +00:00
|
|
|
|
|
|
|
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])
|
2020-07-01 03:24:03 +00:00
|
|
|
|
|
|
|
// 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: rectsInCoordinateSpaceOfEnclosingRect)
|
|
|
|
let maskLayer = CAShapeLayer()
|
|
|
|
maskLayer.path = path.cgPath
|
|
|
|
snapshot.layer.mask = maskLayer
|
2020-06-25 14:42:46 +00:00
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
2020-07-01 03:24:03 +00:00
|
|
|
// 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.addSubview(snapshot)
|
|
|
|
|
|
|
|
return UITargetedPreview(view: snapshotContainer, parameters: parameters, target: target)
|
2020-06-25 14:42:46 +00:00
|
|
|
}
|
|
|
|
|
2020-01-18 21:00:38 +00:00
|
|
|
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
|
|
|
if let viewController = animator.previewViewController {
|
|
|
|
animator.preferredCommitStyle = .pop
|
|
|
|
animator.addCompletion {
|
|
|
|
self.navigationDelegate?.show(viewController)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2020-06-25 14:42:46 +00:00
|
|
|
|
|
|
|
/// 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]?
|
|
|
|
}
|
2020-01-18 21:00:38 +00:00
|
|
|
}
|