Tusker/Tusker/Views/HTMLContentLabel.swift

306 lines
9.8 KiB
Swift

//
// StatusContentLabel.swift
// Tusker
//
// Created by Shadowfacts on 8/25/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftSoup
protocol HTMLContentLabelDelegate {
func selected(mention: Mention)
func selected(tag: Hashtag)
func selected(url: URL)
}
class HTMLContentLabel: UILabel {
var delegate: HTMLContentLabelDelegate?
override var text: String? {
didSet {
parseHTML()
}
}
override var attributedText: NSAttributedString? {
didSet {
updateTextStorage()
}
}
override var numberOfLines: Int {
didSet {
textContainer.maximumNumberOfLines = numberOfLines
}
}
override var lineBreakMode: NSLineBreakMode {
didSet {
textContainer.lineBreakMode = lineBreakMode
}
}
// var status: Status! {
// didSet {
// text = status.content
// }
// }
private var _customizing = true
private lazy var textStorage = NSTextStorage()
private lazy var layoutManager = NSLayoutManager()
private lazy var textContainer = NSTextContainer()
private var selectedLink: (range: NSRange, url: URL)?
private var links: [NSRange: URL] = [:]
override init(frame: CGRect) {
super.init(frame: frame)
_customizing = false
setupLabel()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
_customizing = false
setupLabel()
}
private func setupLabel() {
textStorage.addLayoutManager(layoutManager)
layoutManager.addTextContainer(textContainer)
textContainer.lineFragmentPadding = 0
textContainer.lineBreakMode = lineBreakMode
textContainer.maximumNumberOfLines = numberOfLines
isUserInteractionEnabled = true
}
override func awakeFromNib() {
super.awakeFromNib()
updateTextStorage()
}
override var intrinsicContentSize: CGSize {
let superSize = super.intrinsicContentSize
textContainer.size = CGSize(width: superSize.width, height: .greatestFiniteMagnitude)
let size = layoutManager.usedRect(for: textContainer)
return CGSize(width: ceil(size.width), height: ceil(size.height))
}
override func drawText(in rect: CGRect) {
let range = NSRange(location: 0, length: textStorage.length)
textContainer.size = rect.size
let origin = rect.origin
layoutManager.drawBackground(forGlyphRange: range, at: origin)
layoutManager.drawGlyphs(forGlyphRange: range, at: origin)
}
// MARK: - HTML parsing
private func parseHTML() {
if _customizing { return }
guard let text = text else { return }
let doc = try! SwiftSoup.parse(text)
let body = doc.body()!
let (attributedText, links) = attributedTextForHTMLNode(body)
self.links = links
let mutAttrString = NSMutableAttributedString(attributedString: attributedText)
mutAttrString.trimCharactersInSet(.whitespacesAndNewlines)
mutAttrString.addAttribute(.font, value: font, range: NSRange(location: 0, length: mutAttrString.length))
self.attributedText = mutAttrString
}
private func attributedTextForHTMLNode(_ node: Node) -> (NSAttributedString, [NSRange: URL]) {
switch node {
case let node as TextNode:
return (NSAttributedString(string: node.text()), [:])
case let node as Element:
var links = [NSRange: URL]()
let attributed = NSMutableAttributedString()
node.getChildNodes().forEach { child in
let (text, childLinks) = attributedTextForHTMLNode(child)
childLinks.forEach { range, url in
let newRange = NSRange(location: range.location + attributed.length, length: range.length)
links[newRange] = url
}
attributed.append(text)
}
switch node.tagName() {
case "br":
attributed.append(NSAttributedString(string: "\n"))
case "a":
if let link = try? node.attr("href"),
let url = URL(string: link) {
let linkRange = NSRange(location: 0, length: attributed.length)
let linkAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.blue
]
attributed.setAttributes(linkAttributes, range: linkRange)
links[linkRange] = url
}
case "p":
attributed.append(NSAttributedString(string: "\n\n"))
default:
break
}
return (attributed, links)
default:
fatalError("Unexpected node type: \(type(of: node))")
}
}
// MARK: - Text Storage
private func updateTextStorage() {
if _customizing { return }
guard let attributedText = attributedText,
attributedText.length > 0 else {
links = [:]
textStorage.setAttributedString(NSAttributedString())
setNeedsDisplay()
return
}
textStorage.setAttributedString(attributedText)
_customizing = true
text = attributedText.string
_customizing = false
setNeedsDisplay()
}
// MARK: - Interaction
func getMention(for url: URL, text: String) -> Mention? {
// todo: figure out how to get account IDs
return nil
}
func getTag(for url: URL, text: String) -> Hashtag? {
if text.starts(with: "#") {
let tag = String(text.dropFirst())
return Hashtag(name: tag, url: url)
} else {
return nil
}
}
private func onTouch(_ touch: UITouch) -> Bool {
let location = touch.location(in: self)
var avoidSuperCall = false
switch touch.phase {
case .began, .moved:
if let link = link(at: location) {
if link.range.location != selectedLink?.range.location || link.range.length != selectedLink?.range.length {
updateAttributesWhenSelected(false)
selectedLink = link
updateAttributesWhenSelected(true)
}
avoidSuperCall = true
} else {
updateAttributesWhenSelected(false)
selectedLink = nil
}
case .ended:
guard let selectedLink = selectedLink else { return avoidSuperCall }
let text = String(self.text![Range(selectedLink.range, in: self.text!)!])
if let delegate = delegate {
if let mention = getMention(for: selectedLink.url, text: text) {
delegate.selected(mention: mention)
} else if let tag = getTag(for: selectedLink.url, text: text) {
delegate.selected(tag: tag)
} else {
delegate.selected(url: selectedLink.url)
}
}
let when = DispatchTime.now() + Double(Int64(0.25 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)
DispatchQueue.main.asyncAfter(deadline: when) {
self.updateAttributesWhenSelected(false)
self.selectedLink = nil
}
case .cancelled:
updateAttributesWhenSelected(false)
selectedLink = nil
case .stationary:
break
}
return avoidSuperCall
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if onTouch(touch) { return }
super.touchesBegan(touches, with: event)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if onTouch(touch) { return }
super.touchesMoved(touches, with: event)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if onTouch(touch) { return }
super.touchesCancelled(touches, with: event)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if onTouch(touch) { return }
super.touchesEnded(touches, with: event)
}
private func link(at location: CGPoint) -> (range: NSRange, url: URL)? {
guard textStorage.length > 0 else { return nil }
let boundingRect = layoutManager.boundingRect(forGlyphRange: NSRange(location: 0, length: textStorage.length), in: textContainer)
guard boundingRect.contains(location) else {
return nil
}
let index = layoutManager.glyphIndex(for: location, in: textContainer)
for (range, url) in links {
if index >= range.location && index <= range.location + range.length {
return (range, url)
}
}
return nil
}
private func updateAttributesWhenSelected(_ isSelected: Bool) {
guard let selectedLink = selectedLink else { return }
var attributes = textStorage.attributes(at: 0, effectiveRange: nil)
attributes[.foregroundColor] = isSelected ? nil : UIColor.blue
textStorage.addAttributes(attributes, range: selectedLink.range)
setNeedsDisplay()
}
}