// // 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, with event: UIEvent?) { guard let touch = touches.first else { return } if onTouch(touch) { return } super.touchesBegan(touches, with: event) } override func touchesMoved(_ touches: Set, with event: UIEvent?) { guard let touch = touches.first else { return } if onTouch(touch) { return } super.touchesMoved(touches, with: event) } override func touchesCancelled(_ touches: Set, with event: UIEvent?) { guard let touch = touches.first else { return } if onTouch(touch) { return } super.touchesCancelled(touches, with: event) } override func touchesEnded(_ touches: Set, 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() } }