2018-08-28 01:27:34 +00:00
|
|
|
//
|
|
|
|
// StatusContentLabel.swift
|
|
|
|
// Tusker
|
|
|
|
//
|
|
|
|
// Created by Shadowfacts on 8/25/18.
|
|
|
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
|
|
//
|
|
|
|
|
|
|
|
import UIKit
|
2018-09-11 14:52:21 +00:00
|
|
|
import Pachyderm
|
2018-08-28 01:27:34 +00:00
|
|
|
import SwiftSoup
|
|
|
|
|
|
|
|
protocol HTMLContentLabelDelegate {
|
|
|
|
|
|
|
|
func selected(mention: Mention)
|
|
|
|
|
2018-09-11 14:52:21 +00:00
|
|
|
func selected(tag: Hashtag)
|
2018-08-28 01:27:34 +00:00
|
|
|
|
|
|
|
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)
|
2018-08-30 01:51:04 +00:00
|
|
|
mutAttrString.trimCharactersInSet(.whitespacesAndNewlines)
|
2018-08-28 01:27:34 +00:00
|
|
|
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
|
|
|
|
}
|
2018-08-30 01:51:04 +00:00
|
|
|
case "p":
|
|
|
|
attributed.append(NSAttributedString(string: "\n\n"))
|
2018-08-28 01:27:34 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2018-09-11 14:52:21 +00:00
|
|
|
func getTag(for url: URL, text: String) -> Hashtag? {
|
2018-08-28 01:27:34 +00:00
|
|
|
if text.starts(with: "#") {
|
|
|
|
let tag = String(text.dropFirst())
|
2018-09-11 14:52:21 +00:00
|
|
|
return Hashtag(name: tag, url: url)
|
2018-08-28 01:27:34 +00:00
|
|
|
} 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()
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|