Tusker/Tusker/Views/ContentTextView.swift

301 lines
13 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
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
class ContentTextView: LinkTextView {
// todo: should be weak
var navigationDelegate: TuskerNavigationDelegate?
var defaultFont: UIFont = .systemFont(ofSize: 17)
var defaultColor: UIColor = .label
override func awakeFromNib() {
super.awakeFromNib()
delegate = self
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<UIImage>(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 = self.createEmojiTextAttachment(image: emojiImage, index: match.range.location)
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
}
self.attributedText = mutAttrString
self.setNeedsLayout()
self.setNeedsDisplay()
}
}
// Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m
private func createEmojiTextAttachment(image: UIImage, index: Int) -> NSTextAttachment {
let font = self.font!
let adjustedCapHeight = font.capHeight - 1
var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight)
let defaultScale: CGFloat = 1.4
imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * defaultScale, height: imageSizeMatchingFontSize.height * defaultScale)
let textColor = self.textColor ?? UIColor.label
UIGraphicsBeginImageContextWithOptions(imageSizeMatchingFontSize, false, 0.0)
textColor.set()
image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize))
let attachmentImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
let attachment = NSTextAttachment()
attachment.image = attachmentImage
return attachment
}
// 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
}
}
// only handles link taps via the gesture recognizer which is used when selection is disabled
@objc func textTapped(_ recognizer: UITapGestureRecognizer) {
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)
} else if let tag = getHashtag(for: url, text: text) {
return HashtagTimelineViewController(for: tag)
} 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) {
let preview: UIContextMenuContentPreviewProvider = {
self.getViewController(forLink: link, inRange: range)
}
let actions: UIContextMenuActionProvider = { (_) in
let text = (self.text as NSString).substring(with: range)
let actions: [UIAction]
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)
}
return UIContextMenuConfiguration(identifier: nil, previewProvider: preview, actionProvider: actions)
} else {
return nil
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let viewController = animator.previewViewController {
animator.preferredCommitStyle = .pop
animator.addCompletion {
self.navigationDelegate?.show(viewController)
}
}
}
}