NetNewsWire/Mac/MainWindow/Detail/DetailWebViewController.swift

362 lines
11 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// DetailWebViewController.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/11/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import AppKit
@preconcurrency import WebKit
import RSCore
import RSWeb
import Articles
protocol DetailWebViewControllerDelegate: AnyObject {
func mouseDidEnter(_: DetailWebViewController, link: String)
func mouseDidExit(_: DetailWebViewController)
}
final class DetailWebViewController: NSViewController {
weak var delegate: DetailWebViewControllerDelegate?
var webView: DetailWebView!
var state: DetailState = .noSelection {
didSet {
if state != oldValue {
switch state {
case .article(_, let scrollY), .extracted(_, _, let scrollY):
windowScrollY = scrollY
default:
break
}
reloadHTML()
}
}
}
var article: Article? {
switch state {
case .article(let article, _):
return article
case .extracted(let article, _, _):
return article
default:
return nil
}
}
private var articleTextSize = AppDefaults.shared.articleTextSize
private var webInspectorEnabled: Bool {
get {
return webView.configuration.preferences._developerExtrasEnabled
}
set {
webView.configuration.preferences._developerExtrasEnabled = newValue
}
}
private lazy var articleIconSchemeHandler = ArticleIconSchemeHandler(delegate: self)
private var waitingForFirstReload = false
private let keyboardDelegate = DetailKeyboardDelegate()
private var windowScrollY: CGFloat?
private var isShowingExtractedArticle: Bool {
switch state {
case .extracted(_, _, _):
return true
default:
return false
}
}
private struct MessageName {
static let mouseDidEnter = "mouseDidEnter"
static let mouseDidExit = "mouseDidExit"
static let windowDidScroll = "windowDidScroll"
}
override func loadView() {
let configuration = WebViewConfiguration.configuration(with: articleIconSchemeHandler)
webView = DetailWebView(frame: NSRect.zero, configuration: configuration)
webView.uiDelegate = self
webView.navigationDelegate = self
webView.keyboardDelegate = keyboardDelegate
webView.translatesAutoresizingMaskIntoConstraints = false
if let userAgent = UserAgent.fromInfoPlist() {
webView.customUserAgent = userAgent
}
view = webView
// Hide the web view until the first reload (navigation) is complete (plus some delay) to avoid the awful white flash that happens on the initial display in dark mode.
// See bug #901.
webView.isHidden = true
waitingForFirstReload = true
webInspectorEnabled = AppDefaults.shared.webInspectorEnabled
NotificationCenter.default.addObserver(self, selector: #selector(webInspectorEnabledDidChange(_:)), name: .WebInspectorEnabledDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(currentArticleThemeDidChangeNotification(_:)), name: .CurrentArticleThemeDidChangeNotification, object: nil)
webView.loadFileURL(ArticleRenderer.blank.url, allowingReadAccessTo: ArticleRenderer.blank.baseURL)
}
// MARK: Notifications
@objc func feedIconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func userDefaultsDidChange(_ note: Notification) {
if articleTextSize != AppDefaults.shared.articleTextSize {
articleTextSize = AppDefaults.shared.articleTextSize
reloadHTMLMaintainingScrollPosition()
}
}
@objc func currentArticleThemeDidChangeNotification(_ note: Notification) {
reloadHTMLMaintainingScrollPosition()
}
// MARK: Media Functions
func stopMediaPlayback() {
webView.evaluateJavaScript("stopMediaPlayback();")
}
// MARK: Scrolling
func canScrollDown(_ completion: @escaping (Bool) -> Void) {
fetchScrollInfo { (scrollInfo) in
completion(scrollInfo?.canScrollDown ?? false)
}
}
func canScrollUp(_ completion: @escaping (Bool) -> Void) {
fetchScrollInfo { (scrollInfo) in
completion(scrollInfo?.canScrollUp ?? false)
}
}
override func scrollPageDown(_ sender: Any?) {
webView.scrollPageDown(sender)
}
override func scrollPageUp(_ sender: Any?) {
webView.scrollPageUp(sender)
}
// MARK: State Restoration
func saveState(to state: inout [AnyHashable : Any]) {
state[UserInfoKey.isShowingExtractedArticle] = isShowingExtractedArticle
state[UserInfoKey.articleWindowScrollY] = windowScrollY
}
}
// MARK: - ArticleIconSchemeHandlerDelegate
extension DetailWebViewController: ArticleIconSchemeHandlerDelegate {
func articleIconSchemeHandler(_: ArticleIconSchemeHandler, imageForArticleID articleID: String) -> IconImage? {
guard let article else {
assertionFailure("Did not expect request for article image when there is no current article.")
return nil
}
guard articleID == article.articleID else {
assertionFailure("Expected articleID to match current articleID.")
return nil
}
return article.iconImage() // May be nil  not a programming error
}
}
// MARK: - WKScriptMessageHandler
extension DetailWebViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == MessageName.windowDidScroll {
windowScrollY = message.body as? CGFloat
} else if message.name == MessageName.mouseDidEnter, let link = message.body as? String {
delegate?.mouseDidEnter(self, link: link)
} else if message.name == MessageName.mouseDidExit {
delegate?.mouseDidExit(self)
}
}
}
// MARK: - WKNavigationDelegate & WKUIDelegate
extension DetailWebViewController: WKNavigationDelegate, WKUIDelegate {
// Bottleneck through which WebView-based URL opens go
func openInBrowser(_ url: URL, flags: NSEvent.ModifierFlags) {
let invert = flags.contains(.shift) || flags.contains(.command)
Browser.open(url.absoluteString, invertPreference: invert)
}
// WKNavigationDelegate
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated {
if let url = navigationAction.request.url {
self.openInBrowser(url, flags: navigationAction.modifierFlags)
}
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// See note in viewDidLoad()
if waitingForFirstReload {
assert(webView.isHidden)
waitingForFirstReload = false
reloadHTML()
// Waiting for the first navigation to complete isn't long enough to avoid the flash of white.
// A hard coded value is awful, but 5/100th of a second seems to be enough.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
webView.isHidden = false
}
} else {
if let windowScrollY = windowScrollY {
webView.evaluateJavaScript("window.scrollTo(0, \(windowScrollY));")
self.windowScrollY = nil
}
}
}
// WKUIDelegate
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
// This method is reached when WebKit handles a JavaScript based window.open() invocation, for example. One
// example where this is used is in YouTube's embedded video player when a user clicks on the video's title
// or on the "Watch in YouTube" button. For our purposes we'll handle such window.open calls the same way we
// handle clicks on a URL.
if let url = navigationAction.request.url {
self.openInBrowser(url, flags: navigationAction.modifierFlags)
}
return nil
}
}
// MARK: - Private
private extension DetailWebViewController {
func reloadArticleImage() {
guard let article = article else { return }
var components = URLComponents()
components.scheme = ArticleRenderer.imageIconScheme
components.path = article.articleID
if let imageSrc = components.string {
webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")")
}
}
func reloadHTMLMaintainingScrollPosition() {
fetchScrollInfo() { scrollInfo in
self.windowScrollY = scrollInfo?.offsetY
self.reloadHTML()
}
}
func reloadHTML() {
delegate?.mouseDidExit(self)
let theme = ArticleThemesManager.shared.currentTheme
let rendering: ArticleRenderer.Rendering
switch state {
case .noSelection:
rendering = ArticleRenderer.noSelectionHTML(theme: theme)
case .multipleSelection:
rendering = ArticleRenderer.multipleSelectionHTML(theme: theme)
case .loading:
rendering = ArticleRenderer.loadingHTML(theme: theme)
case .article(let article, _):
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
case .extracted(let article, let extractedArticle, _):
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, theme: theme)
}
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: URL(string: rendering.baseURL))
}
func fetchScrollInfo(_ completion: @escaping (ScrollInfo?) -> Void) {
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: self.webView.frame.height, offsetY: offsetY)
completion(scrollInfo)
}
}
@objc func webInspectorEnabledDidChange(_ notification: Notification) {
self.webInspectorEnabled = notification.object! as! Bool
}
}
// 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
}
}