385 lines
11 KiB
Swift
385 lines
11 KiB
Swift
//
|
|
// WebViewController.swift
|
|
// Multiplatform macOS
|
|
//
|
|
// Created by Maurice Parker on 7/8/20.
|
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
|
//
|
|
|
|
import AppKit
|
|
import Combine
|
|
import RSCore
|
|
import Articles
|
|
|
|
extension Notification.Name {
|
|
static let appleColorPreferencesChangedNotification = Notification.Name("AppleColorPreferencesChangedNotification")
|
|
static let appleInterfaceThemeChangedNotification = Notification.Name("AppleInterfaceThemeChangedNotification")
|
|
}
|
|
|
|
protocol WebViewControllerDelegate: class {
|
|
func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState)
|
|
}
|
|
|
|
class WebViewController: NSViewController {
|
|
|
|
private struct MessageName {
|
|
static let imageWasClicked = "imageWasClicked"
|
|
static let imageWasShown = "imageWasShown"
|
|
static let mouseDidEnter = "mouseDidEnter"
|
|
static let mouseDidExit = "mouseDidExit"
|
|
static let showFeedInspector = "showFeedInspector"
|
|
}
|
|
|
|
var statusBarView: WebStatusBarView!
|
|
|
|
private var webView: PreloadedWebView?
|
|
|
|
private var articleExtractor: ArticleExtractor? = nil
|
|
var extractedArticle: ExtractedArticle?
|
|
var isShowingExtractedArticle = false
|
|
|
|
var articleExtractorButtonState: ArticleExtractorButtonState = .off {
|
|
didSet {
|
|
delegate?.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState)
|
|
}
|
|
}
|
|
|
|
var sceneModel: SceneModel?
|
|
weak var delegate: WebViewControllerDelegate?
|
|
|
|
var articles: [Article]? {
|
|
didSet {
|
|
if oldValue != articles {
|
|
loadWebView()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var selectedArticlesCancellable: AnyCancellable?
|
|
|
|
override func loadView() {
|
|
view = NSView()
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
|
|
DistributedNotificationCenter.default().addObserver(self, selector: #selector(appleColorPreferencesChanged(_:)), name: .appleColorPreferencesChangedNotification, object: nil)
|
|
DistributedNotificationCenter.default().addObserver(self, selector: #selector(appleInterfaceThemeChanged(_:)), name: .appleInterfaceThemeChangedNotification, object: nil)
|
|
|
|
statusBarView = WebStatusBarView()
|
|
statusBarView.translatesAutoresizingMaskIntoConstraints = false
|
|
self.view.addSubview(statusBarView)
|
|
NSLayoutConstraint.activate([
|
|
self.view.leadingAnchor.constraint(equalTo: statusBarView.leadingAnchor, constant: -6),
|
|
self.view.trailingAnchor.constraint(greaterThanOrEqualTo: statusBarView.trailingAnchor, constant: 6),
|
|
self.view.bottomAnchor.constraint(equalTo: statusBarView.bottomAnchor, constant: 2),
|
|
statusBarView.heightAnchor.constraint(equalToConstant: 20)
|
|
])
|
|
|
|
selectedArticlesCancellable = sceneModel?.timelineModel.$selectedArticles.sink { [weak self] articles in
|
|
self?.articles = articles
|
|
}
|
|
}
|
|
|
|
// MARK: Notifications
|
|
|
|
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
|
|
reloadArticleImage()
|
|
}
|
|
|
|
@objc func avatarDidBecomeAvailable(_ note: Notification) {
|
|
reloadArticleImage()
|
|
}
|
|
|
|
@objc func faviconDidBecomeAvailable(_ note: Notification) {
|
|
reloadArticleImage()
|
|
}
|
|
|
|
@objc func appleColorPreferencesChanged(_ note: Notification) {
|
|
loadWebView()
|
|
}
|
|
|
|
@objc func appleInterfaceThemeChanged(_ note: Notification) {
|
|
loadWebView()
|
|
}
|
|
|
|
// MARK: API
|
|
|
|
func focus() {
|
|
webView?.becomeFirstResponder()
|
|
}
|
|
|
|
func canScrollDown(_ completion: @escaping (Bool) -> Void) {
|
|
fetchScrollInfo { (scrollInfo) in
|
|
completion(scrollInfo?.canScrollDown ?? false)
|
|
}
|
|
}
|
|
|
|
override func scrollPageDown(_ sender: Any?) {
|
|
webView?.scrollPageDown(sender)
|
|
}
|
|
|
|
func toggleArticleExtractor() {
|
|
|
|
guard let article = articles?.first else {
|
|
return
|
|
}
|
|
|
|
guard articleExtractor?.state != .processing else {
|
|
stopArticleExtractor()
|
|
loadWebView()
|
|
return
|
|
}
|
|
|
|
guard !isShowingExtractedArticle else {
|
|
isShowingExtractedArticle = false
|
|
loadWebView()
|
|
articleExtractorButtonState = .off
|
|
return
|
|
}
|
|
|
|
if let articleExtractor = articleExtractor {
|
|
if article.preferredLink == articleExtractor.articleLink {
|
|
isShowingExtractedArticle = true
|
|
loadWebView()
|
|
articleExtractorButtonState = .on
|
|
}
|
|
} else {
|
|
startArticleExtractor()
|
|
}
|
|
|
|
}
|
|
|
|
func stopArticleExtractorIfProcessing() {
|
|
if articleExtractor?.state == .processing {
|
|
stopArticleExtractor()
|
|
}
|
|
}
|
|
|
|
func stopWebViewActivity() {
|
|
if let webView = webView {
|
|
stopMediaPlayback(webView)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: ArticleExtractorDelegate
|
|
|
|
extension WebViewController: ArticleExtractorDelegate {
|
|
|
|
func articleExtractionDidFail(with: Error) {
|
|
stopArticleExtractor()
|
|
articleExtractorButtonState = .error
|
|
loadWebView()
|
|
}
|
|
|
|
func articleExtractionDidComplete(extractedArticle: ExtractedArticle) {
|
|
if articleExtractor?.state != .cancelled {
|
|
self.extractedArticle = extractedArticle
|
|
isShowingExtractedArticle = true
|
|
loadWebView()
|
|
articleExtractorButtonState = .on
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
// MARK: WKScriptMessageHandler
|
|
|
|
extension WebViewController: WKScriptMessageHandler {
|
|
|
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
switch message.name {
|
|
case MessageName.imageWasShown:
|
|
return
|
|
case MessageName.imageWasClicked:
|
|
return
|
|
case MessageName.mouseDidEnter:
|
|
if let link = message.body as? String {
|
|
statusBarView.mouseoverLink = link
|
|
}
|
|
case MessageName.mouseDidExit:
|
|
statusBarView.mouseoverLink = nil
|
|
case MessageName.showFeedInspector:
|
|
return
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension WebViewController: WKNavigationDelegate {
|
|
|
|
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
if navigationAction.navigationType == .linkActivated {
|
|
if let url = navigationAction.request.url {
|
|
let flags = navigationAction.modifierFlags
|
|
let invert = flags.contains(.shift) || flags.contains(.command)
|
|
Browser.open(url.absoluteString, invertPreference: invert)
|
|
}
|
|
decisionHandler(.cancel)
|
|
return
|
|
}
|
|
|
|
decisionHandler(.allow)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: Private
|
|
|
|
private extension WebViewController {
|
|
|
|
func loadWebView() {
|
|
if let webView = webView {
|
|
self.renderPage(webView)
|
|
return
|
|
}
|
|
|
|
sceneModel?.webViewProvider?.dequeueWebView() { webView in
|
|
|
|
// Add the webview
|
|
self.webView = webView
|
|
|
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
|
self.view.addSubview(webView, positioned: .below, relativeTo: self.statusBarView)
|
|
NSLayoutConstraint.activate([
|
|
self.view.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
|
|
self.view.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
|
|
self.view.topAnchor.constraint(equalTo: webView.topAnchor),
|
|
self.view.bottomAnchor.constraint(equalTo: webView.bottomAnchor)
|
|
])
|
|
|
|
webView.navigationDelegate = self
|
|
|
|
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked)
|
|
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown)
|
|
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.mouseDidEnter)
|
|
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.mouseDidExit)
|
|
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.showFeedInspector)
|
|
|
|
self.renderPage(webView)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
func renderPage(_ webView: PreloadedWebView) {
|
|
let style = ArticleStylesManager.shared.currentStyle
|
|
let rendering: ArticleRenderer.Rendering
|
|
|
|
if articles?.count ?? 0 > 1 {
|
|
rendering = ArticleRenderer.multipleSelectionHTML(style: style)
|
|
} else if let articleExtractor = articleExtractor, articleExtractor.state == .processing {
|
|
rendering = ArticleRenderer.loadingHTML(style: style)
|
|
} else if let articleExtractor = articleExtractor, articleExtractor.state == .failedToParse, let article = articles?.first {
|
|
rendering = ArticleRenderer.articleHTML(article: article, style: style)
|
|
} else if let article = articles?.first, let extractedArticle = extractedArticle {
|
|
if isShowingExtractedArticle {
|
|
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, style: style)
|
|
} else {
|
|
rendering = ArticleRenderer.articleHTML(article: article, style: style)
|
|
}
|
|
} else if let article = articles?.first {
|
|
rendering = ArticleRenderer.articleHTML(article: article, style: style)
|
|
} else {
|
|
rendering = ArticleRenderer.noSelectionHTML(style: style)
|
|
}
|
|
|
|
let substitutions = [
|
|
"title": rendering.title,
|
|
"baseURL": rendering.baseURL,
|
|
"style": rendering.style,
|
|
"body": rendering.html
|
|
]
|
|
|
|
let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions)
|
|
webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL)
|
|
|
|
}
|
|
|
|
func fetchScrollInfo(_ completion: @escaping (ScrollInfo?) -> Void) {
|
|
guard let webView = webView else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
let javascriptString = "var x = {contentHeight: document.body.scrollHeight, offsetY: window.pageYOffset}; x"
|
|
|
|
webView.evaluateJavaScript(javascriptString) { (info, error) in
|
|
guard let info = info as? [String: Any] else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
guard let contentHeight = info["contentHeight"] as? CGFloat, let offsetY = info["offsetY"] as? CGFloat else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
let scrollInfo = ScrollInfo(contentHeight: contentHeight, viewHeight: webView.frame.height, offsetY: offsetY)
|
|
completion(scrollInfo)
|
|
}
|
|
}
|
|
|
|
func startArticleExtractor() {
|
|
if let link = articles?.first?.preferredLink, let extractor = ArticleExtractor(link) {
|
|
extractor.delegate = self
|
|
extractor.process()
|
|
articleExtractor = extractor
|
|
articleExtractorButtonState = .animated
|
|
}
|
|
}
|
|
|
|
func stopArticleExtractor() {
|
|
articleExtractor?.cancel()
|
|
articleExtractor = nil
|
|
isShowingExtractedArticle = false
|
|
articleExtractorButtonState = .off
|
|
}
|
|
|
|
func reloadArticleImage() {
|
|
guard let article = articles?.first else { return }
|
|
|
|
var components = URLComponents()
|
|
components.scheme = ArticleRenderer.imageIconScheme
|
|
components.path = article.articleID
|
|
|
|
if let imageSrc = components.string {
|
|
webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")")
|
|
}
|
|
}
|
|
|
|
func stopMediaPlayback(_ webView: WKWebView) {
|
|
webView.evaluateJavaScript("stopMediaPlayback();")
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - ScrollInfo
|
|
|
|
private struct ScrollInfo {
|
|
|
|
let contentHeight: CGFloat
|
|
let viewHeight: CGFloat
|
|
let offsetY: CGFloat
|
|
let canScrollDown: Bool
|
|
let canScrollUp: Bool
|
|
|
|
init(contentHeight: CGFloat, viewHeight: CGFloat, offsetY: CGFloat) {
|
|
self.contentHeight = contentHeight
|
|
self.viewHeight = viewHeight
|
|
self.offsetY = offsetY
|
|
|
|
self.canScrollDown = viewHeight + offsetY < contentHeight
|
|
self.canScrollUp = offsetY > 0.1
|
|
}
|
|
|
|
}
|