280 lines
12 KiB
Swift
280 lines
12 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 {
|
|
|
|
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
|
|
|
|
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 = 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
|
|
}
|
|
}
|
|
|
|
// 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, 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) {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|